Docs/Build & operate
Deployment guide

Deployment

Run Probara with local processes, Docker Compose, or Kubernetes and Helm, with explicit guidance for databases, NATS, ingress, private locations, migrations, scaling, and current packaging limitations.

Choose a deployment workflow

WorkflowBest forWhat runs where
make start-all-localFeature development and debuggingPostgreSQL and NATS in Docker; locally built Go services and Next.js UI on the host.
make start-allTesting backend container images locallyPostgreSQL, NATS, and Go services in Compose; Next.js UI still runs on the host.
docker compose up -d / make upRaw backend Compose lifecycleCompose services only. There is no frontend service in the current root Compose file.
Helm chartKubernetes staging and productionFrontend, services, migration hook, and optionally embedded PostgreSQL/NATS in the cluster.

Prerequisites

  • Docker with Compose v2 for PostgreSQL, NATS, migrations, and container workflows.
  • GNU Make and a Bash-compatible environment for repository scripts. On Windows, use WSL or another environment that can execute the Makefile’s Bash commands.
  • Go 1.26.5 for the main monorepo. The local launcher compares the installed major/minor version with the root go.mod directive.
  • Node.js LTS and npm for the frontend. scripts/start-ui.sh can use nvm when it is available.
  • Helm 3 and kubectl for Kubernetes deployment.
  • A PostgreSQL 16-compatible database and NATS 2.10 with JetStream for production when not using embedded chart dependencies.

The host agent (probara-collector) is not a separate Go module: scripts/build-collector.sh generates and cross-compiles a minimal OpenTelemetry Collector distribution from collector/manifest.yaml with a pinned builder version. Root make test covers the whole repository.

Prepare required values

Create local environment values
cp .env.example .env

# Generate stable secrets; keep them across restarts and upgrades.
openssl rand -hex 32       # ADMIN_JWT_SECRET
openssl rand -base64 32    # PROBARA_SECRETS_KEY
  • Set ADMIN_JWT_SECRET to a random value of at least 32 characters. Validation only rejects an empty value, so shipped placeholder defaults must be replaced deliberately.
  • Set PUBLIC_BASE_URL to the externally reachable API origin. For local use, http://localhost:8080 is appropriate.
  • Set PROBARA_SECRETS_KEY before storing monitor credentials, notification secrets, location credentials, or tenant AI keys; see encryption keys for format and rotation.
  • Do not set PUBLIC_NATS_URL until NATS authentication and an externally reachable TLS/WSS endpoint are ready.

Local-process stack

Start and stop
make start-all-local

# Logs
tail -f /tmp/probara-*.log
tail -f /tmp/probara-ui.log

# Matching lifecycle
make restart-all-local
make stop-all-local

The target starts PostgreSQL and NATS, bootstraps local database access, validates Go, runs migrations, builds downloadable collector binaries, builds and launches each Go service, and starts Next.js on 0.0.0.0:3000.

.dev-secrets.key
Gitignored, persistent local encryption key created by the launcher.
/tmp/probara-*.pid and /tmp/probara-*.log
Local service process tracking and logs used by the start/stop scripts.

Docker Compose stack

Docker-backed backend with local UI
make start-all
make ps
make logs
make healthcheck
make stop-all
Raw Compose operations
docker compose up -d
docker compose ps
docker compose logs -f
docker compose down
ComponentHost portContainer behavior
Frontend3000Started on the host by Make; not a Compose service.
API HTTP, health, readiness, metrics8080All API routes, including /metrics, use the HTTP listener.
API declared metrics port9090Mapped by Compose, but the API currently does not start a separate listener there.
Scheduler metrics/health9091The scheduler’s real HTTP listener.
Scheduler declared HTTP port8081Mapped by Compose, but scheduler has no listener on HTTP_PORT.
Worker metrics/health/mesh9092Metrics listener and mesh echo.
Worker dedicated mesh HTTPNot published (8083 inside container)Starts because HTTP and metrics ports differ; Compose exposes only metrics port.
Status pages8082Public rendered pages and status HTTP routes.
Status metrics/health9093Separate operational listener.
Alerter metrics/health9094Only published alerter listener.
PostgreSQL5432Development database, user/database/password probara.
NATS4222Client connection; no local authentication or TLS.
NATS monitor8222NATS HTTP monitoring endpoint.

Compose contract gaps

  • API, scheduler, and worker all use stream check-jobs and subject check.job, so scheduled and on-demand jobs share one subject contract. If you change these values, change them on all three services together.
  • The root .env file is not passed as a service env_file; only variables explicitly declared in Compose reach containers. PROBARA_SECRETS_KEY is forwarded from the shell environment to the api, scheduler, worker, and alerter services.
  • AI, SMTP, asynchronous notifications, public NATS authorization, scheduler mesh/purge, and many result-ingest settings are not wired.
  • The commented private-location worker example omits required LOCATION_CREDENTIAL and should not be enabled as written.
  • The API and worker correctly share the synthetic_artifacts volume used for failure screenshots. Trace and HAR capture are configured by the schema but are not implemented by the current worker.

Install with Helm

Minimal values.prod.yaml
image:
  tag: "pin-an-immutable-release"

secrets:
  adminJwtSecret: "replace-with-at-least-32-random-characters"
  probaraSecretsKey: "base64-encoded-32-byte-key"

api:
  publicBaseURL: "https://probara.example.com"

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: probara.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: probara-tls
      hosts:
        - probara.example.com
Install or upgrade
helm lint ./helm/monitoring-platform \
  --set secrets.adminJwtSecret="$(openssl rand -hex 32)" \
  --set api.publicBaseURL="https://probara.example.com"

helm upgrade --install probara ./helm/monitoring-platform \
  --namespace probara \
  --create-namespace \
  --values values.prod.yaml

kubectl -n probara get pods,svc,ingress
kubectl -n probara get jobs

Helm values: images and infrastructure

ValueDefaultMeaning
global.imageRegistryEmptyDeclared but currently not used when rendering image names.
global.imagePullPolicyIfNotPresentGlobal backend pull policy; also wins over the frontend-specific pull policy in current templates.
image.repositoryghcr.io/yassinebenameur/probaraBackend image base repository.
image.taglatestBackend image tag. Pin for production.
image.pullSecrets[]Backend registry pull secrets.
postgresql.enabledtrueDeploy embedded PostgreSQL StatefulSet.
postgresql.externalUrlEmptyUsed when embedded PostgreSQL is disabled. Required in that mode unless an existing Secret supplies the DSN — an empty external URL now fails the render instead of starting pods with a blank DSN.
postgresql.existingSecret, .existingSecretKeyEmpty, postgres_urlRead the full DSN from a Secret you supply instead of externalUrl or the embedded credentials. Works in either mode and takes precedence over both.
postgresql.imagepostgres:16-alpineEmbedded database image.
postgresql.auth.usernameprobaraEmbedded database user.
postgresql.auth.passwordprobara-secretEmbedded password; replace it.
postgresql.auth.databaseprobaraEmbedded database name.
postgresql.auth.existingSecret, .existingSecretUserKey, .existingSecretPasswordKeyEmpty, postgresql_username, postgresql_passwordEmbedded-server credentials from a Secret you supply. Requires the DSN to be externalized too — via postgresql.existingSecret or the chart-wide secrets.existingSecret — because Helm cannot compose a DSN out of values it is not allowed to read. The render fails if neither is set.
postgresql.persistence.enabledtrueUse a PVC for database data.
postgresql.persistence.size10GiDatabase PVC request.
postgresql.persistence.storageClassEmptyCluster default storage class.
postgresql.resources100m/256Mi request; 500m/512Mi limitEmbedded database resources.
nats.enabledtrueDeploy embedded NATS StatefulSet.
nats.externalUrlEmptyUsed when embedded NATS is disabled.
nats.existingSecret, .existingSecretKeyEmpty, nats_urlRead the full broker URL — credentials included — from a Secret you supply. Required alongside nats.auth.platformPasswordExistingSecret on the embedded broker (or covered by the chart-wide secrets.existingSecret), since the chart otherwise has to compose the URL around a password it cannot read.
nats.auth.platformPasswordExistingSecret, .platformPasswordExistingSecretKeyEmpty, nats_platform_passwordPlatform-user password for the embedded broker. It reaches nats-server as a --pass argument, substituted by Kubernetes from a Secret-backed environment variable, so no password is written into the ConfigMap in either mode. It deliberately does not travel through nats.conf — nats-server re-parses an expanded $VAR as configuration, which breaks purely numeric or space-bearing passwords.
nats.auth.locationIssuerSeedExistingSecret, .locationIssuerSeedExistingSecretKeyEmpty, nats_location_auth_issuer_seedAuth-callout account seed for the API. The matching public key stays a plain ConfigMap value.
nats.imagenats:2.10-alpineEmbedded broker image.
nats.persistence.enabledtruePersist JetStream state.
nats.persistence.size1GiNATS PVC request.
nats.persistence.storageClassEmptyCluster default storage class.
nats.resources50m/64Mi request; 200m/256Mi limitEmbedded NATS resources.

Helm values: auth and service workloads

Value familyDefaultsMeaning / caveat
extraEnv, <service>.extraEnv[]Standard EnvVar entries appended to workloads: top-level extraEnv reaches every workload (including the migrations job); per-service lists (api, scheduler, worker, alerter, statusPage, frontend, migrations) reach one. worker.extraEnv also applies to location workers, and each worker.locations entry may carry its own extraEnv. Use this for any supported runtime variable without a dedicated chart value (LLM, rotation keys, SSRF policy).
smtp.host, .port, .useTLS, .from, .username, .password, .existingSecret, .existingSecretKey"", 465, true, "", "", "", "", passwordMail transport for email alert channels, rendered into the API, worker, and alerter together — the API needs it for the channel test endpoint, not only the alerter. Empty host disables email delivery. useTLS selects implicit TLS (pair with port 465), not STARTTLS. Prefer existingSecret over an inline password.
auth.auditRetentionDays365Audit retention days; 0 preserves records indefinitely.
auth.oidc.*Disabled; blank issuer/client; scopes openid profile email; groups claim groups; label SSO; JIT viewer/default tenantPlatform OIDC settings. Secret is secrets.oidcClientSecret. Add groups to auth.oidc.scopes when using OIDC group mappings.
secrets.existingSecretEmptyChart-wide fallback Secret backing every credential without a per-field reference. When set the chart renders no Secret of its own. See “Secrets from an external secret manager” below for the key names.
secrets.adminJwtSecretInsecure placeholder defaultShips as change-me-in-production-jwt-secret-minimum-32-chars; no template validation rejects it, so replace it with at least 32 random characters, or point secrets.adminJwtSecretExistingSecret at a Secret.
secrets.initialApiKeyInsecure placeholderStored and printed by notes but not consumed by any bootstrap path. It does not create an API key.
secrets.probaraSecretsKeyEmptyBase64 32-byte encryption key, provided to the API, scheduler, worker, and alerter. Supplied by either the literal or an existing Secret; with neither, the key is absent and at-rest encryption stays off.
secrets.oidcClientSecretEmptyRequired when OIDC is enabled — the render now fails if neither it nor secrets.oidcClientSecretExistingSecret is set, instead of leaving the API pod unable to start on a missing Secret key.
secrets.<field>ExistingSecret, .<field>ExistingSecretKeyEmpty; canonical key namePer-field overrides for adminJwtSecret, probaraSecretsKey, and oidcClientSecret. They win over secrets.existingSecret and may point at different Secrets, so chart-managed, chart-wide, and per-field sources mix field by field.
migrations.enabledtrueCreates a post-install/post-upgrade migration hook Job.
migrations.image.repository, .tag, .resourcesDerived image; 50m/64Mi request, 200m/256Mi limitOverride migration image independently when needed.
api.enabled, .replicas, .httpPort, .metricsPort, .logLeveltrue, 2, 8080, 9090, infoAPI workload. publicBaseURL is required when enabled.
api.publicBaseURL, .publicNatsURLEmptyExternal API origin and optional external TLS/WSS NATS endpoint.
scheduler.enabled, .replicas, .metricsPort, .logLeveltrue, 1, 9090, infoScheduling and retention workload.
scheduler.checkJobStream, .checkJobSubject, .intervalSecondscheck-jobs, check.job, 5Must match worker and API. Current API subject wiring does not match.
scheduler.retentionCleanup*true, hour 2, batch 5000, max 200000Scheduled telemetry retention limits.
worker.enabled, .replicas, .metricsPort, .httpPort, .logLeveltrue, 2, 9090, 8080, infoDefault check-execution fleet and mesh echo port.
worker.checkJobStream, .checkJobSubject, .consumerName, .concurrencycheck-jobs, check.job, worker, 10Queue contract and worker parallelism.
worker.autoscaling.*Disabled; 2–10 replicas; 80% CPUCreates an HPA for the default worker deployment.
alerter.enabled, .replicas, .metricsPort, .logLeveltrue, 2, 9090, infoAlert evaluation workload. SMTP comes from the top-level smtp block; other advanced alert settings are not chart values.

Each service group also accepts a standard resources.requests and resources.limits object. Defaults are intentionally small and should be sized from observed check volume, job latency, database load, and notification throughput.

Helm values: status, frontend, ingress, and pod policy

Value familyDefaultsMeaning / caveat
statusPage.enabled, .replicas, .httpPort, .metricsPort, .logLeveltrue, 2, 8080, 9090, infoPublic status renderer.
statusPage.baseUrlEmptyPassed as STATUS_PAGE_BASE_URL, which currently has no runtime use.
statusPage.service.*ClusterIP, port 8080, null NodePortsPublic/metrics NodePorts may be fixed only when using NodePort.
frontend.enabled, .replicas, .httpPorttrue, 2, 3000Next.js deployment.
frontend.apiUrl, .statusPageUrl, .apiProxyTarget/api, empty, auto in-cluster APIPublic variables are build-time in Next.js; proxy target is runtime server-side.
frontend.image.*Dedicated frontend repository, latest tag, IfNotPresent, no secretsPin tag. Global pull policy currently masks the frontend-specific pull policy.
frontend.service.*ClusterIP, port 3000Frontend Service settings.
ingress.enabled, .className, .annotations, .hosts, .tlsDisabled; nginx; example frontend and status hostsThe current routing template gives / to frontend and /api to API; see warning below.
serviceAccount.create, .annotations, .nametrue, {}, emptyWorkload service account selection.
podAnnotationsPrometheus scrape on port 9090 at /metricsApplied to workloads; verify API metrics reachability because API serves metrics on its HTTP listener.
podSecurityContextfsGroup/runAsUser 1000; non-rootPod-level identity defaults.
securityContextNo privilege escalation; drop all capabilities; writable root filesystemContainer hardening defaults. Read-only root remains disabled.

Secrets from an external secret manager

Every credential the chart consumes can come from a Secret you supply instead of a literal in your values file. The chart takes no dependency on any particular tool: External Secrets Operator, the Vault agent injector, sealed-secrets, SOPS, and a hand-written kubectl create secret all end at the same place — a Secret in the release namespace — and the chart only ever names it.

Each field resolves its source in a fixed order: its own <field>ExistingSecret, then the chart-wide secrets.existingSecret, then the Secret the chart renders from your literal values. Sources mix field by field, so an install can keep the JWT chart-managed while the encryption key comes from Vault and the database DSN from a cloud secret store.

Chart-wide: one Secret behind everything
secrets:
  # The chart then renders no Secret of its own.
  existingSecret: probara-platform-secrets
Per field: different stores for different credentials
postgresql:
  enabled: false
  existingSecret: rds-probara       # key: postgres_url (override with existingSecretKey)

nats:
  existingSecret: nats-platform     # key: nats_url

secrets:
  adminJwtSecret: "kept-in-the-values-file-for-this-install"
  probaraSecretsKeyExistingSecret: vault-probara
  probaraSecretsKeyExistingSecretKey: encryption_key
  oidcClientSecretExistingSecret: vault-probara

worker:
  locations:
    - name: eu-west
      locationId: "location-uuid-from-probara"
      existingSecret: probara-location-eu-west
KeyNeeded whenRead by
postgres_urlAlwaysAPI, scheduler, worker, alerter, status page, migrations job
nats_urlAlwaysAPI, scheduler, worker, alerter, status page
admin_jwt_secretAlwaysAPI
probara_secrets_keyAlways in chart-wide modeAPI, scheduler, worker, alerter. In chart-wide mode the chart assumes the Secret carries it rather than silently running with at-rest encryption off.
postgresql_username, postgresql_passwordpostgresql.enabledEmbedded PostgreSQL StatefulSet
nats_platform_passwordnats.auth.enabled on the embedded brokerEmbedded NATS server, as a --pass argument substituted by Kubernetes
nats_location_auth_issuer_seednats.auth.enabledAPI
oidc_client_secretauth.oidc.enabledAPI
  • Per-field <field>ExistingSecretKey values default to the canonical key names above, so a Secret laid out with those names needs no key overrides.
  • A key the chart asks for but the Secret does not carry leaves the pod in CreateContainerConfigError. That is deliberate: a missing encryption key should stop a rollout, not quietly disable encryption.
  • Helm cannot read Secrets, so it cannot assemble a URL around a value it does not have: postgresql.auth.existingSecret needs the DSN externalized too, and nats.auth.platformPasswordExistingSecret needs the broker URL externalized. Either the matching per-field reference or the chart-wide secrets.existingSecret satisfies that; setting neither fails the render with that explanation.
  • The Secret must exist before the pods start. With External Secrets Operator, that means the ExternalSecret must have synced — the migrations Job runs as a post-install/post-upgrade hook and needs postgres_url at that moment.
  • Rotating a value in the Secret does not restart the workloads; the chart sets no checksum annotation. Restart the affected Deployments yourself after a rotation.
  • Rotation keys (PROBARA_SECRETS_KEY_V2 and higher) have no dedicated value and go through the top-level extraEnv, which accepts a valueFrom.secretKeyRef entry like any Kubernetes EnvVar.

External PostgreSQL and NATS

External infrastructure values
postgresql:
  enabled: false
  externalUrl: "postgres://probara:REDACTED@postgres.example.internal:5432/probara?sslmode=require"

nats:
  enabled: false
  externalUrl: "tls://probara-platform:REDACTED@nats.example.internal:4222"

api:
  publicBaseURL: "https://probara.example.com"
  # Set only if private locations can reach this broker address.
  publicNatsURL: "tls://nats.example.com:4222"
  • Use TLS certificate verification for both PostgreSQL and NATS. Do not copy the example with sslmode=disable into production.
  • Provision JetStream storage for CHECK_JOBS, CHECK_RESULTS, alert, notification, and AI workloads.
  • Keep the platform NATS credential separate from per-location credentials and restrict broker/network access.
  • External URLs written as literals live in your values file. Point postgresql.existingSecret and nats.existingSecret at Secrets instead to keep them out of it — either way the chart reads both URLs from a Secret at runtime, never from a PodSpec.
  • Test migrations against a backup before changing application versions.

Deploy private-location workers

A private-location worker needs only an authenticated NATS endpoint, its location UUID, and its location credential. It must not receive direct PostgreSQL access. Generate deploy information in the Locations UI/API after NATS authorization is configured.

In-cluster Helm location fleet
worker:
  locations:
    - name: eu-west
      locationId: "location-uuid-from-probara"
      credential: "generated-location-credential"
      natsUrl: "tls://location-id:credential@nats.example.com:4222"
      # Or replace both with a Secret in the release namespace:
      #   existingSecret: probara-location-eu-west
      #   natsUrlKey: nats_url          # default
      #   credentialKey: credential     # default
      replicas: 1
      concurrency: "10"
      meshService:
        enabled: true
        type: LoadBalancer
        annotations:
          service.beta.kubernetes.io/aws-load-balancer-internal: "true"
  • Use a DNS-safe unique name; each entry creates a separate Deployment.
  • Set existingSecret on an entry to take natsUrl and credential from a Secret instead of the values file; the literals are then no longer required and no location credential appears in the PodSpec.
  • The chart does not expose HTTP egress policy; provide HTTP_BLOCK_PRIVATE_IPS and HTTP_ALLOWED_CIDRS through extra environment configuration, and keep any allowlist limited to networks that location is explicitly trusted to monitor.
  • Expose the mesh echo endpoint only on private inter-location networks.
  • For locations outside the Kubernetes cluster, use the generated container/deployment snippet rather than granting database access.
  • The embedded NATS Service is ClusterIP-only; an external location needs a separate TLS/WSS exposure path.

Scaling and availability

ComponentScaling guidance
APIStateless around PostgreSQL/NATS and configured for two chart replicas. Preserve shared JWT/encryption/preview secrets across every replica.
SchedulerChart defaults to one replica. Treat it as a singleton unless the scheduling/retention coordination semantics have been explicitly validated.
Default workerScale manually or enable HPA. Queue consumer semantics distribute work across replicas.
Location workerScale each location independently; every replica consumes only that location’s filtered job subject.
AlerterChart defaults to two replicas, but validate duplicate-evaluation/notification behavior under your policies and NATS mode.
Status page and frontendStateless application replicas; status cache invalidation benefits from NATS updates.
PostgreSQL and NATSBundled dependencies are simple single StatefulSets, not production HA operators. Use managed or operator-backed systems for stronger recovery objectives.
Local worker scaling
make scale-workers N=3

Migrations, upgrades, and rollback

  1. Back up PostgreSQL and record the running application/chart/image versions.
  2. Render or lint the target chart with production values and inspect Secrets, Services, queue variables, and image tags.
  3. Run the target migrations against a disposable copy or staging database.
  4. Deploy the migration-compatible application version. The chart migration Job is a post-install/post-upgrade hook; API startup also runs shared migrations.
  5. Verify /readyz, result ingestion, scheduled and on-demand checks, alert delivery, status invalidation, and private locations.
  6. Roll back application images only when the schema remains backward-compatible. Database migrations are not automatically reversed by helm rollback.

Current Helm packaging gaps

GapOperational impact
No shared browser-artifact storageAPI replicas cannot reliably serve artifacts produced on worker filesystems.
initialApiKey is unusedInstallation notes show a credential that authentication never recognizes.
Public Next variables are runtime-onlyPrebuilt frontend client links may retain build-time values.
API metrics Service points to an inactive separate portAPI actually serves /metrics on its HTTP listener.
Status-root ingress routes to frontendDefault second-host example does not publish the status service at /.
global.imageRegistry and generic ConfigMap are unusedChanging them does not alter workload behavior.
Embedded NATS is ClusterIP-onlyPrivate locations outside the cluster cannot connect without an additional secure exposure.

Production checklist

  • Pin immutable backend, frontend, collector, migration, PostgreSQL, and NATS versions.
  • Use managed/HA PostgreSQL and NATS or define tested backup and restore objectives for embedded state.
  • Configure HTTPS, ADMIN_COOKIE_SECURE=true, a correct PUBLIC_BASE_URL, and trusted reverse-proxy headers.
  • Use stable random JWT, encryption, OIDC, preview, SMTP, webhook, database, and NATS secrets, sourced from your secret manager rather than written into the values file.
  • Align every NATS stream, subject, consumer, and status-update subject across services.
  • Keep private-destination blocking enabled and allow only narrowly scoped CIDRs.
  • Share or externalize synthetic-browser artifact storage.
  • Provide secure TLS/WSS NATS exposure before enabling remote private locations.
  • Correct ingress routing and verify browser client URLs in the built frontend image.
  • Scrape the listeners that actually serve /metrics; alert on readiness failures, result-ingest errors, queue backlog, and migration failure.
  • Run the complete verification suite and a restore drill before launch.