Skip to content

Deployment

jstuart0 edited this page May 5, 2026 · 4 revisions

Deployment

Three deployment shapes are first-class: local Docker, Docker Compose behind Traefik, and Kubernetes with Authentik SSO. Pick the lightest one that fits.

Local (single user, single machine)

docker run -d \
  --name agentpulse \
  -p 3000:3000 \
  -v agentpulse-data:/app/data \
  -e DISABLE_AUTH=true \
  ghcr.io/jstuart0/agentpulse:latest

That's it. Open http://localhost:3000.

To persist AI features across restarts, also set:

  -e AGENTPULSE_AI_ENABLED=true \
  -e AGENTPULSE_SECRETS_KEY=$(openssl rand -hex 32) \

AGENTPULSE_SECRETS_KEY must be 32+ characters. Write it down — if you lose it, all encrypted credentials (LLM API keys, Telegram bot token) become unrecoverable. Store it in a password manager.

Homelab (Docker Compose behind Traefik)

# compose.yaml
services:
  agentpulse:
    image: ghcr.io/jstuart0/agentpulse:latest
    volumes:
      - agentpulse-data:/app/data
    environment:
      AGENTPULSE_AI_ENABLED: "true"
      AGENTPULSE_SECRETS_KEY: "${AGENTPULSE_SECRETS_KEY}"
    labels:
      traefik.enable: "true"
      # Dashboard (behind Authentik)
      traefik.http.routers.agentpulse.rule: "Host(`agentpulse.example.com`)"
      traefik.http.routers.agentpulse.middlewares: "authentik-forwardauth@file"
      traefik.http.routers.agentpulse.tls.certresolver: "le"
      # Public hook + webhook paths — NO auth middleware
      traefik.http.routers.agentpulse-public.rule: "Host(`agentpulse.example.com`) && (PathPrefix(`/api/v1/hooks`) || PathPrefix(`/api/v1/channels/telegram/webhook`) || PathPrefix(`/setup`))"
      traefik.http.routers.agentpulse-public.tls.certresolver: "le"

volumes:
  agentpulse-data:

Two Traefik routers: one with Authentik forwardauth for the dashboard, one without for paths that need to be publicly reachable (hook ingest, Telegram webhook, setup scripts).

Kubernetes (reference manifests)

The repo's deploy/k8s/ directory has the manifests used in production:

  • 00-namespace.yaml
  • 01-secret-template.yaml — env vars + AGENTPULSE_SECRETS_KEY (create this out-of-band, never commit).
  • 02-configmap.yaml — non-secret config.
  • 03-pvc.yaml — persistent volume claim for /app/data (Ceph-backed in the reference setup).
  • 04-deployment.yaml — deployment (SQLite default; Postgres overlay enables multi-replica).
  • 05-service.yaml — ClusterIP.
  • 06-middleware.yaml — Traefik forwardauth + https-redirect middleware.
  • 07-ingressroute.yaml — multiple routes: public hooks, public Telegram webhook, public setup scripts, protected dashboard.
kubectl create secret generic agentpulse-secrets \
  -n agentpulse \
  --from-literal=AGENTPULSE_SECRETS_KEY=$(openssl rand -hex 32)

kubectl apply -k deploy/k8s/

Postgres (multi-replica)

As of v0.4.0, multi-replica deployments are supported via the deploy/overlays/postgres/ Kustomize overlay. The overlay:

  • Sets DATABASE_URL to a Postgres connection string (fill in secret-patch.yaml, gitignored).
  • Removes the SQLite backup sidecar.
  • Switches the deployment strategy to RollingUpdate.

Rolling deploys are safe: each replica acquires a session-level pg_advisory_lock on its migration client connection before running Drizzle migrations. Two replicas booting simultaneously serialize on that lock; no external coordination needed.

# Fill in deploy/overlays/postgres/secret-patch.yaml (see the .example file)
kubectl apply -f deploy/overlays/postgres/secret-patch.yaml -n agentpulse
kubectl apply -k deploy/overlays/postgres/

See deploy/overlays/postgres/README.md for the full pre-flight checklist.

Connection pool tuning: AGENTPULSE_PG_POOL_MAX (integer [1, 100], default 10). Size based on your Postgres max_connections and replica count.

SQLite + WAL continues to handle single-pod deployments fine. Use the base manifests (kubectl apply -k deploy/k8s/) for single-replica SQLite deployments.

Env vars reference

Name Default Required Description
PORT 3000 Bun HTTP port.
HOST 0.0.0.0 Bind address.
DISABLE_AUTH unset true skips all dashboard + hook auth. Local only.
DATABASE_URL "" (SQLite) Postgres connection string. When set to a postgres://... URL, AgentPulse uses PostgreSQL. Leave unset for SQLite. Example: postgres://agentpulse:pw@host:5432/agentpulse?sslmode=require.
AGENTPULSE_PG_POOL_MAX 10 Postgres connection pool size. Integer [1, 100]. Only relevant when DATABASE_URL is set.
AGENTPULSE_LEGACY_INIT unset SQLite existing-install path. Set "false" to force Drizzle migrate on an existing SQLite install.
DATA_DIR /app/data SQLite file location (ignored when Postgres is active).
PUBLIC_URL unset for webhook mode Canonical external URL. If unset, the Telegram wizard accepts the browser's window.location.origin.
AGENTPULSE_AI_ENABLED false to enable AI Compile-time toggle for the whole AI control plane.
AGENTPULSE_SECRETS_KEY unset if AI is on 32+ char key used to encrypt LLM API keys, Telegram tokens, webhook secrets. Losing it breaks all stored credentials.
AGENTPULSE_ALLOW_SIGNUP true First-run admin signup when users table is empty. Set false to disable.
AGENTPULSE_LOCAL_ADMIN_USERNAME unset Bootstrap admin — recreated each boot from env.
AGENTPULSE_LOCAL_ADMIN_PASSWORD unset Paired with above. 12+ chars required.
AGENTPULSE_OTEL_ENDPOINT unset OpenTelemetry collector URL for ai_metric events.
AGENTPULSE_TELEMETRY on Set off to opt out of anonymous usage pings.
TELEGRAM_BOT_TOKEN unset Legacy bootstrap — prefer the in-app paste-token wizard.
TELEGRAM_WEBHOOK_SECRET unset Same — legacy.

Authentik SSO trust gate (Kubernetes)

The Kubernetes manifests use three Traefik middlewares to wire Authentik SSO for the dashboard. The chain runs in this order on every protected route:

  1. agentpulse-strip-client-authentik — removes any X-Authentik-* headers the client supplied, preventing header-forgery attacks.
  2. agentpulse-forwardauth — Authentik validates the session and injects identity headers (X-authentik-username, X-authentik-email, etc.) in its response; Traefik copies them upstream.
  3. agentpulse-inject-verify — Traefik appends the X-Authentik-Verify shared secret. AgentPulse verifies this value against AGENTPULSE_AUTHENTIK_TRUST_SECRET before admitting the identity.

The inject-verify middleware is the key piece: Authentik's Proxy Property Mappings populate JWT id_token claims, not forwardauth response headers, so the header injection must happen at the Traefik layer instead. See deploy/k8s/AUTHENTIK-FORWARDAUTH.md in the repo for the full setup steps, Kustomize overlay pattern for the shared secret, and secret rotation procedure.

Setup summary:

# 1. Generate shared secret
SECRET=$(openssl rand -hex 32)

# 2. Add to the agentpulse Secret
kubectl -n agentpulse patch secret agentpulse-secrets \
  --type='json' \
  -p='[{"op":"add","path":"/data/AGENTPULSE_AUTHENTIK_TRUST_SECRET","value":"'"$(echo -n "$SECRET" | base64)"'"}]'

# 3. Inject the same value into agentpulse-inject-verify via your private Kustomize overlay
#    (do not commit it to the base manifest — base uses an empty placeholder)

# 4. Apply and restart
kubectl apply -k deploy/k8s-homelab/
kubectl -n agentpulse rollout restart deployment/agentpulse

Public-facing routes (must bypass auth)

Path Why
/api/v1/health Liveness / startup probes.
/api/v1/ready Readiness probe — kubelet has no Authentik session.
/api/v1/hooks Hook ingest. Authenticated by API key bearer.
/api/v1/hooks/status Semantic status updates. Same auth.
/assets/* Vite-built JS/CSS chunks. Browsers need these before any auth session exists; Authentik 302s break Vite's dynamic import with a MIME mismatch.
/api/v1/auth/me, /api/v1/auth/login, /api/v1/auth/logout, /api/v1/auth/signup Login-page bootstrap and local-account auth flows. The login page calls /auth/me to detect whether the user is already signed in.
/api/v1/channels/telegram/webhook Telegram bot callback. Authenticated by HMAC secret header.
/setup.sh, /setup-relay.sh, /install-local.sh, /install-local.ps1 Setup scripts. Anyone who can reach the server can pull them.
/api/v1/supervisors/* Supervisor agents on remote machines have no Authentik session; per-endpoint requireSupervisorAuth() is the auth boundary.

/api/v1/auth/change-password is intentionally not bypassed — it enforces requireAuth() in-handler.

Everything else must be behind whatever dashboard auth you use (Authentik, local accounts, DISABLE_AUTH).

Backups

One file: /app/data/agentpulse.db (plus .db-shm and .db-wal during operation). Stop the container, copy it, start it back. Or use SQLite's .backup command for online backups.

The encrypted credentials are useless without the AGENTPULSE_SECRETS_KEY — store it somewhere separate from your DB backups.

Upgrades

Pull the new image, run the old container down, start the new one up. Migrations are idempotent (CREATE TABLE IF NOT EXISTS + ALTER TABLE ADD COLUMN with retry-on-lock). No downtime migration step required.

SQLite: rolling k8s upgrades work but hit brief write contention during the migration window — the migration loop retries locks with exponential backoff so the new pod never starts against a stale schema. For this reason, the SQLite base manifests use Recreate strategy.

Postgres: rolling upgrades are safe (no write contention during migration) because the pg_advisory_lock serializes replica boot across the migration step. The Postgres overlay uses RollingUpdate.

Observability

  • HealthGET /api/v1/health.
  • Logs — structured JSON on stdout (one {"type":"ai_metric",...} per watcher run, standard Hono access logs).
  • OTel — set AGENTPULSE_OTEL_ENDPOINT to forward metrics + traces to your collector.
  • DiagnosticsGET /api/v1/ai/diagnostics returns queue depth, flags, provider reachability.

Clone this wiki locally