Skip to content

Docker Compose Deployment ​

Docker Compose is the way to run Productify on one machine: for a real self-hosted install (the versioned install bundle), for a quick evaluation of the Manager alone, or for development. All three are below, in that order, followed by Pocket ID setup, operations, backup and troubleshooting.

For day-to-day development use pfy dev up && pfy dev seed instead (see the Zero-to-App Local Quickstart); it generates and starts a Compose stack for you. This page is for stacks you manage yourself.

Prerequisites ​

  • Docker Engine 20.10+ with the Compose plugin 2.x (docker compose, not the legacy docker-compose binary).
  • 4 GB RAM minimum (8 GB recommended) and 10 GB of free disk.
  • Internet access to pull images on the first run. The install bundle also needs ports 80 and 443 free on the host.
bash
docker --version           # Docker version 20.10.x or higher
docker compose version     # Docker Compose version 2.x.x
docker ps                  # must not error

Choosing a stack ​

You want toUseSection
Install Productify for real on one box (proxy, Pocket ID login, TLS)deployments/compose/ install bundleSelf-hosted install bundle
Try the Manager API/UI with no login, no proxyThe evaluation stack belowEvaluation stack
Develop the Manager or proxy from sourceEach repo's own docker-compose.ymlDevelopment setup

Evaluation stack (Manager only) ​

A stack you can boot on one machine to look at the Manager. It is not a production configuration: it runs the Manager in development mode with the authentication bypass on and TLS to the database off, because there is no proxy and no certificate authority in front of it. For a real deployment use the install bundle.

Run it from a directory that contains a checkout of the manager repo at the same version as the image (the migration one-shot mounts the migration files out of it, and the schema must match the binary). Replace latest with the release tag you are evaluating.

yaml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: productify
      POSTGRES_USER: productify
      POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U productify"]
      interval: 10s
      timeout: 5s
      retries: 5

  # The Manager never migrates on boot, in any run mode, so a fresh database is
  # schema-migrated by this one-shot before anything else starts. `manager
  # -migrate` from the same image does the same job with nothing mounted; see
  # Database migrations below.
  migrate:
    image: arigaio/atlas:latest
    working_dir: /app
    command: >
      migrate apply
      --dir file://ent/migrate/migrations
      --url postgres://productify:${DB_PASSWORD:-changeme}@postgres:5432/productify?sslmode=disable
    volumes:
      - ./manager/ent/migrate/migrations:/app/ent/migrate/migrations:ro
    depends_on:
      postgres:
        condition: service_healthy

  manager:
    image: ghcr.io/productifyfw/manager:2.0.0
    ports:
      - "8080:8080"
      - "8081:8081" # /healthz and /readyz
    environment:
      # PFY_ENV defaults to production. Leave it out and the Manager refuses to
      # boot here: production requires a Pocket ID API key, an encryption key,
      # and rejects sslmode=disable.
      PFY_ENV: development
      PFY_RUN_MODE: both

      PFY_DB_HOST: postgres
      PFY_DB_PORT: 5432
      PFY_DB_USER: productify
      PFY_DB_PASSWORD: ${DB_PASSWORD:-changeme}
      PFY_DB_NAME: productify
      # PFY_DB_SSLMODE defaults to require; the stock postgres image serves no
      # TLS, so this stack opts out explicitly. Never do this in production.
      PFY_DB_SSLMODE: disable

      # There is no proxy in front of this stack, so no request can carry the
      # X-Proxy-Auth secret. Without the dev-user override the Manager fails
      # closed and answers every single request with a bodyless 401.
      PFY_DEV_USER_OVERRIDE_ENABLED: "true"
      PFY_DEV_USER_OVERRIDE_ID: ca53cb60-e72b-47b5-a7a3-9fa48dda681c
      PFY_DEV_USER_OVERRIDE_EMAIL: dev@productify.local
      PFY_DEV_USER_OVERRIDE_NAME: Local Dev
      PFY_DEV_USER_OVERRIDE_USERNAME: dev
    depends_on:
      postgres:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    restart: unless-stopped

volumes:
  postgres_data:

Run:

bash
docker compose up -d
curl -fsS http://localhost:8081/readyz   # {"status":"ready", ...}
curl -fsS http://localhost:8081/healthz  # {"status":"healthy","version":"...", ...}

/readyz answers 200 only after the migrate one-shot has applied the schema and the Manager can reach the database; it also lists warnings (for example the noop orchestrator). If the manager container restarts in a loop, read docker compose logs manager: the first line names the rejected setting.

dev_user_override is an authentication bypass

It assigns the fixed identity above to every unauthenticated request. It only takes effect when PFY_ENV=development, and the Manager refuses to start if it is enabled in any other environment, but that only protects you from shipping it by accident. Do not expose this stack beyond localhost.

Self-hosted install bundle ​

The install bundle lives in the deployments repository under compose/. It is the versioned way to run the whole platform with Compose: Manager (manager + executor), PostgreSQL (serving TLS), Pocket ID, the Caddy proxy and an optional example app and frontend. Every image is pinned, every install-specific value is in one .env, and one script brings it up. Its docker-compose.yml and Caddyfile are derived from the platform renderer's Compose target (pfy platform render --target compose), so it has the same shape as a rendered install.

ServiceImageRole
managerghcr.io/productifyfw/manager:2.0.0Manager API + UI (PFY_RUN_MODE=api)
executorghcr.io/productifyfw/manager:2.0.0Trigger and sweep executor (PFY_RUN_MODE=executor)
postgrespostgres:16.4Manager database, serving TLS
postgres-certspostgres:16.4One-shot: generates the database's self-signed certificate
migratearigaio/atlas:1.3.3-community-alpineOne-shot: applies ./migrations; manager waits on it
pocket-idghcr.io/pocket-id/pocket-id:v2.14.0OIDC identity provider, served at https://<ID_DOMAIN> through the proxy
proxyghcr.io/productifyfw/proxy:1.1.0Caddy + caddy-security + the productify plugin; the only service publishing ports (80, 443)
example-appghcr.io/productifyfw/example-app:0.1.0Optional demo backend, profile example
example-frontendghcr.io/productifyfw/example-frontend:0.1.0Optional demo SPA, profile example
FileWhat it is
docker-compose.ymlThe stack, images pinned. Never edit tags here; take a newer bundle instead.
CaddyfileThe proxy prelude: global options, the Pocket ID login portal, the Manager vhost and the Pocket ID vhost. Per-app vhosts are not here; the Manager renders and pushes them.
.env.exampleEvery install value and the secret slots install.sh fills. Copy to .env.
install.shPreflight, secret generation, compose up, post-boot verification, --make-admin.
install_test.shTests for the installer's guards, including that migrations/ is byte-identical to the Manager's ent/migrate/migrations at the pinned Manager ref.
migrations/The Manager's ent/migrate/migrations at the pinned Manager version, with atlas.sum. Do not edit.
../RELEASES.mdCompatibility matrix (bundle, Manager, proxy, Pocket ID, Postgres, CLI versions), upgrade path, backup/restore.

Install ​

bash
git clone https://github.com/ProductifyFW/deployments.git
cd deployments/compose
cp .env.example .env
# edit: MANAGER_DOMAIN, ID_DOMAIN, APPS_WILDCARD_DOMAIN, ACME_EMAIL, ADMIN_EMAIL
./install.sh --check-only      # validates .env + Caddyfile, starts nothing
./install.sh --no-verify       # pass 1: generates secrets, starts the stack

Three names must resolve to this host: MANAGER_DOMAIN, ID_DOMAIN and the *.apps wildcard (APPS_WILDCARD_DOMAIN). For Let's Encrypt they must also be reachable from the internet on 80/443. install.sh prints the records to set.

install.sh is idempotent: it never regenerates an existing secret and converges the running stack. Flags: --check-only, --no-verify (skip the post-boot checks; --no-seed is a deprecated alias with the same meaning), --ci (non-interactive; placeholder values become warnings), --make-admin (below).

.env holds the install values you fill in (MANAGER_DOMAIN, ID_DOMAIN, APPS_WILDCARD_DOMAIN, TLS_MODE, ACME_EMAIL, ADMIN_EMAIL, POCKET_ID_API_KEY, optionally POCKET_ID_STATIC_API_KEY and PFY_PROXY_ADMIN_URL) and the secrets install.sh generates (POSTGRES_PASSWORD, POCKET_ID_ENCRYPTION_KEY, PFY_PROXY_SECRET, OIDC_CLIENT_SECRET, CADDY_JWT_SHARED_KEY, PFY_ENCRYPTION_KEY). The example-app profile reads EXAMPLE_MACHINE_USER_TOKEN and EXAMPLE_APPLICATION_ID, which exist only after an application is onboarded.

The Pocket ID API key is a two-pass install ​

The Manager reads PFY_POCKET_ID_API_KEY and refuses to start in production without it, but Pocket ID issues that key, and only after Pocket ID itself is up and set up. So the first pass brings the stack up with the manager and executor containers restarting, and you finish in Pocket ID:

  1. Open https://<ID_DOMAIN> and complete Pocket ID's first-run setup.
  2. Create an API key (see Generate API Key for Manager) and put it in .env as POCKET_ID_API_KEY=….
  3. Register the OIDC client the proxy uses: client id productify-proxy, client secret = OIDC_CLIENT_SECRET from .env.
  4. Create the user named by ADMIN_EMAIL.
bash
./install.sh                   # pass 2: the Manager now starts and is verified

The unattended alternative is POCKET_ID_STATIC_API_KEY: set it in .env (for example openssl rand -base64 32) and Pocket ID provisions an admin service account holding exactly that key at startup, so with the same value in POCKET_ID_API_KEY a single ./install.sh converges with no browser step. It is a standing admin credential living in .env, whereas a key minted in the Pocket ID UI can be scoped and revoked there without a redeploy, so use it for automation and CI and prefer the two-pass flow when a person is present.

Pass 2 proves the stack works with checks that only real handlers can satisfy; a status code alone proves nothing on the Manager's API port, because its UI catch-all answers every unknown path with 200 and index.html:

CheckWhat it proves
:8081/healthz body contains "status":"healthy"The Manager's own liveness handler ran (health port, not API port).
:8081/readyz body contains "status":"ready"Postgres came up over TLS with the generated password and the migrate one-shot applied ./migrations.
:8080/query answers 401 with no HTMLThe API is routed and the proxy↔Manager trust boundary is enforced.

The first administrator ​

There is no password login. Sign-in is OIDC through Pocket ID; the Manager creates a user row the first time an identity is forwarded to it. An administrator is the column users.role = 'administrator', and nothing in the product sets it, so the sequence is:

  1. Sign in once at https://<MANAGER_DOMAIN> as the ADMIN_EMAIL user.
  2. ./install.sh --make-admin sets the role for ADMIN_EMAIL and reads the row back to confirm. It refuses, with instructions, if that person has not signed in yet.

What the bundle does for you ​

  • Schema migrations. The migrate one-shot (arigaio/atlas) applies the vendored ./migrations before manager starts (depends_on: condition: service_completed_successfully). install.sh pins the atlas.sum hash, so a tampered or mismatched migration set is refused in preflight.
  • TLS to the database. postgres-certs generates a self-signed certificate once; Postgres runs with ssl=on and the Manager connects with PFY_DB_SSLMODE=require (the Manager refuses disable in production).
  • Pocket ID behind the proxy. The Caddyfile serves https://<ID_DOMAIN> and reverse-proxies it to pocket-id:1411; Pocket ID runs with TRUST_PROXY so rate limiting and its audit log see the client address, and the Manager talks to it over the compose network (PFY_POCKET_ID_HOST=http://pocket-id:1411).
  • The Manager vhost. https://<MANAGER_DOMAIN> serves the login portal under /auth/*, and serves /api/cli/login/* (the pfy login device-code flow), /pilet-feed/* and /language-packs/* ahead of the portal, because those callers cannot hold a portal session. Everything else goes through authorize with pocketpolicy to manager:8080 with the proxy secret attached.
  • Proxy vhost sync is on: manager gets PFY_PROXY_ADMIN_URL=http://proxy:2019, PFY_PROXY_PRELUDE_FILE=/etc/caddy/Caddyfile, PFY_PROXY_MANAGER_UPSTREAM=manager:8080 and PFY_PROXY_METRICS_URL=http://proxy:2112, so onboarding an application gives it a working hostname without editing the Caddyfile. Set PFY_PROXY_ADMIN_URL= (empty) in .env to turn it off. See Proxy Vhost Sync.
  • Only 80 and 443 are published. The Manager's ports, the database, Pocket ID, the admin API (2019) and the metrics port (2112) stay on the compose network. Probe them with docker compose exec (see Operations).
  • Example app. docker compose --profile example up -d after install starts example-app and example-frontend; their vhosts are rendered by the Manager once the app is onboarded.

TLS modes ​

TLS_MODE in .env declares the mode; the Caddyfile global block is what Caddy actually reads. install.sh refuses to install when the two disagree and names the missing edit.

Mode.envCaddyfile global block
Public domains, Let's Encrypt (default)TLS_MODE=acme, real ACME_EMAILemail {env.ACME_EMAIL} active
Internal / air-gapped, Caddy's own CATLS_MODE=internalcomment out email, uncomment local_certs; distribute Caddy's root (caddy_data volume, pki/authorities/local/root.crt)
Internal, bring-your-own certificatesTLS_MODE=internalcomment out email; mount the certs into proxy and add a per-site tls /certs/x.crt /certs/x.key

Both internal modes make no outbound TLS calls.

The proxy needs ID_DOMAIN to resolve to itself at startup

caddy-security fetches https://<ID_DOMAIN>/.well-known/openid-configuration while the proxy is starting, and the proxy is what serves that hostname. If ID_DOMAIN does not resolve from inside the proxy container, Caddy fails to load its config and the container restarts until it does. Set DNS (or an extra_hosts entry) before the first boot.

Upgrading ​

Take the newer bundle version, copy your .env into it, back up both databases (below), then docker compose pull && docker compose up -d. The bundle's migrate one-shot applies exactly the new migration files before the new Manager starts; check docker compose logs migrate. Roll back to the previous bundle only if no migration ran; otherwise restore the pre-upgrade backup. Details: deployments/RELEASES.md.

Database migrations ​

The install bundle handles this with its migrate one-shot; read on if you write your own Compose stack.

The Manager never migrates on boot, in any run mode: a fresh database must be migrated once before the server can serve, and every upgrade that ships a migration must be applied before the new image starts.

The Manager image migrates its own database. manager -migrate applies the SQL compiled into the binary through the Atlas Go SDK, so a one-shot of the same image you are deploying is the whole job. Nothing is mounted, nothing is checked out, and the migration set cannot drift from the binary:

yaml
migrate:
  image: ghcr.io/productifyfw/manager:2.0.0
  command: ["/app/manager", "-migrate"]
  environment:
    PFY_DB_HOST: postgres
    PFY_DB_PORT: "5432"
    PFY_DB_USER: ${DB_USER:-productify}
    PFY_DB_PASSWORD: ${DB_PASSWORD}
    PFY_DB_NAME: ${DB_NAME:-productify}
    PFY_DB_SSLMODE: ${DB_SSLMODE:-require}
  depends_on:
    postgres:
      condition: service_healthy

Gate the Manager on it with depends_on: migrate: condition: service_completed_successfully. Applying is idempotent, so re-running it on an already-current database is a no-op, and concurrent manager -migrate runs are serialised by a Postgres advisory lock.

atlas migrate apply against the same database still works and writes the same revision history, so an existing stack built around an arigaio/atlas one-shot can stay as it is or move to the line above at any point. The advisory lock does not cover the Atlas CLI, so do not run both at once.

Running it from a checkout instead

bin/manager -migrate and make migrate-apply DB_URL=... do the same thing on the host and read the same PFY_DB_* settings.

Pocket ID Authentication Setup ​

Pocket ID provides OpenID Connect (OIDC) authentication for the Productify platform. Follow these steps for complete configuration.

1. Initial Setup ​

After starting the containers, access the setup page:

  1. Open Pocket ID's first-run setup page: https://<ID_DOMAIN>/setup on the install bundle, http://pocketid.localhost/setup on the development stacks
  2. Create an administrator account:
    • Username
    • Email address
    • Password
  3. Configure email server (optional but recommended for production)
  4. Set up Passkey (optional, enhances security)

2. Generate API Key for Manager ​

The manager needs an API key to communicate with Pocket ID:

  1. Log in to Pocket ID (https://<ID_DOMAIN>, or http://pocketid.localhost on a development stack)
  2. Go to Settings > Admin > API Keys
  3. Click "Create New API Key"
  4. Enter a name (e.g., "Manager Service")
  5. Copy the generated key (it will only be shown once!)
  6. Give it to the Manager as PFY_POCKET_ID_API_KEY:
bash
# .env of the install bundle; docker-compose.yml passes it to `manager` and
# `executor` as PFY_POCKET_ID_API_KEY. The bundle already points
# PFY_POCKET_ID_HOST at http://pocket-id:1411 over the compose network.
POCKET_ID_API_KEY=paste-your-api-key-here

Then re-run ./install.sh; it recreates the two Manager containers with the key.

The equivalent config file, if you mount one instead of using the environment:

yaml
env: production
port: 8080
db:
  host: postgres
  port: 5432
  user: productify
  password: put-your-password-here
  # The key is `name`, not `dbname`. An unknown key is ignored silently and the
  # Manager then connects to the default database, "postgres".
  name: productify
  # `disable` is rejected outright when env is production.
  sslmode: require
encryption_key: put-your-base64-32-byte-key-here
proxy_secret: put-your-shared-secret-here
pocket_id:
  host: http://pocketid:1411
  api_key: paste-your-api-key-here

Do not put secrets in the file, and do not use ${VAR} in it

The file is plain YAML with no ${VAR} interpolation: a password: ${DB_PASSWORD} line sets the password to that literal string. The environment always wins over the file, so pass PFY_DB_PASSWORD, PFY_ENCRYPTION_KEY, PFY_PROXY_SECRET and PFY_POCKET_ID_API_KEY as variables from your secret store and keep them out of the file entirely.

3. Register OIDC Client for Proxy ​

To enable login through the proxy:

  1. Go to Settings > Admin > OIDC Clients
  2. Click "Create New Client"
  3. Fill in the details:
    • Client ID: productify-proxy (the install bundle's Caddyfile hard-codes this id; the development Caddyfiles use dev-oauth-client-id)
    • Redirect URIs: https://<MANAGER_DOMAIN>/auth/oauth2/generic/authorization-code-callback and one per app hostname (http://*.localhost/auth/oauth2/generic/authorization-code-callback on a development stack)
    • Scopes: openid, email, profile
    • Grant Types: Authorization Code
  4. Save. On the install bundle set the client secret to the value of OIDC_CLIENT_SECRET in .env (install.sh generated it); the Caddyfile reads it as {env.OIDC_CLIENT_SECRET}. On the proxy repo's development stack put the generated secret in .env as PFY_OAUTH_CLIENT_SECRET.

The Caddyfile never holds the secret itself:

nginx
{
  security {
    oauth identity provider generic {
      realm generic
      driver generic
      client_id productify-proxy
      client_secret {env.OIDC_CLIENT_SECRET}
      scopes openid email profile
      metadata_url https://{env.ID_DOMAIN}/.well-known/openid-configuration
    }
  }
}

4. Development Login (No Email) ​

If Passkey is not set up and email is not configured, use one-time access codes:

bash
# Service name is `pocket-id` in the install bundle, `pocketid` in the proxy repo's compose file
docker compose exec pocket-id /app/scripts/create-one-time-access-token.sh <username-or-email>

# Open the printed URL in a browser to log in directly

Security Best Practices ​

Production Security

  • Enable email verification in production
  • Use strong passwords and MFA (Multi-Factor Authentication)
  • Never store API keys or client secrets in version control
  • Use environment variables or encrypted configuration files
  • HTTPS is mandatory in production environments
  • Regularly rotate API keys and secrets

Development Setup ​

Each repo ships a development docker-compose.yml that builds from source: manager/docker-compose.yml (Postgres, the migrate one-shot, the backend under air hot reload on 8080/8081, the Vite frontend on 5173, and an optional executor behind the production profile) and proxy/docker-compose.yml (the proxy built from Dockerfile-local plus Pocket ID; copy proxy/.env.example to .env and fill PFY_MANAGER_TOKEN and PFY_OAUTH_CLIENT_SECRET, and create the .env.pocketid file its pocketid service expects). The proxy's development Caddyfile serves http://pocketid.localhost, http://manager.localhost and http://proxytest.localhost; *.localhost resolves to 127.0.0.1 on most systems.

A combined stack, if you prefer one file:

Development Compose ​

yaml
services:
  postgres:
    image: postgres:16
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: manager_dev
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    volumes:
      - postgres_dev:/var/lib/postgresql/data

  manager:
    build:
      context: ./manager
      dockerfile: Dockerfile.backend
    ports:
      - "8080:8080"
      - "8081:8081"
    environment:
      # Without this the Manager runs as production and refuses to boot: no
      # Pocket ID API key, no encryption key, and sslmode=disable below.
      PFY_ENV: development
      PFY_RUN_MODE: both
      PFY_DB_HOST: postgres
      PFY_DB_USER: dev
      PFY_DB_PASSWORD: dev
      PFY_DB_NAME: manager_dev
      PFY_DB_SSLMODE: disable
      PFY_LOGGER_LEVEL: debug
      # No proxy in this stack: without the override every request gets a
      # bodyless 401 from the proxy-secret gate.
      PFY_DEV_USER_OVERRIDE_ENABLED: "true"
      PFY_DEV_USER_OVERRIDE_ID: ca53cb60-e72b-47b5-a7a3-9fa48dda681c
      PFY_DEV_USER_OVERRIDE_EMAIL: dev@productify.local
      PFY_DEV_USER_OVERRIDE_NAME: Local Dev
      PFY_DEV_USER_OVERRIDE_USERNAME: dev
    volumes:
      - ./manager:/app
    depends_on:
      - postgres
    # The Dockerfile.backend image runs air for hot reload.
    # Migrate once before first use: from ./manager run
    #   make migrate-apply DB_URL='postgres://dev:dev@localhost:5432/manager_dev?sslmode=disable'

  optimizer:
    build:
      context: ./autoscaler/optimizer
      dockerfile: Dockerfile
    ports:
      - "8015:8015"

volumes:
  postgres_dev:

Advanced Configuration ​

Secrets Management ​

Keep secrets out of version control: store them in a .env file (git-ignored) or a mounted config.local.yml, and generate strong values:

bash
# Generate a random database password
openssl rand -hex 32

The Manager reads its configuration from PFY_* environment variables, and optionally from a config.local.yml/config.yml that the environment then overrides. It does not support Docker secrets *_FILE indirection, and the YAML is not interpolated, so a secret has to arrive as an environment variable or as a literal in a mounted file. Prefer the environment; the container image ships no config file, and a container-only deployment is expected to configure the Manager entirely from PFY_*.

Resource Limits ​

yaml
services:
  manager:
    image: ghcr.io/productifyfw/manager:2.0.0
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 2G
        reservations:
          cpus: "0.5"
          memory: 512M

Logging ​

yaml
services:
  manager:
    image: ghcr.io/productifyfw/manager:2.0.0
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Custom Networks ​

yaml
networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
  backend:
    driver: bridge
    internal: true
    ipam:
      config:
        - subnet: 172.21.0.0/16

Monitoring ​

Prometheus ​

yaml
services:
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.path=/prometheus"
    networks:
      - backend

volumes:
  prometheus_data:

prometheus.yml:

yaml
global:
  scrape_interval: 15s

scrape_configs:
  # Executor-mode Manager exposes Prometheus metrics on port 9090
  - job_name: "manager-executor"
    static_configs:
      - targets: ["manager-executor:9090"]

Grafana ​

yaml
services:
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
      - ./grafana/datasources:/etc/grafana/provisioning/datasources:ro
    depends_on:
      - prometheus
    networks:
      - backend

volumes:
  grafana_data:

Operations ​

The commands below assume the install bundle (deployments/compose, project name productify, services manager, executor, postgres, pocket-id, proxy, migrate). Adjust service names for other stacks.

Health checks ​

Nothing but 80/443 is published, so probe from inside the containers. The Manager image is Alpine with busybox wget and no curl.

bash
docker compose ps
docker compose exec manager wget -qO- http://127.0.0.1:8081/healthz   # {"status":"healthy",...}
docker compose exec manager wget -qO- http://127.0.0.1:8081/readyz    # {"status":"ready",...}
docker compose exec proxy   wget -qO- http://127.0.0.1:2112/readyz    # proxy readiness (Manager reachable)
docker compose exec postgres pg_isready -U productify

Logs ​

bash
docker compose logs -f                    # all services
docker compose logs -f manager            # one service
docker compose logs --tail=100 -t manager # last 100 lines, with timestamps
docker compose logs migrate               # why the schema step failed

Updates and scaling ​

bash
docker compose pull && docker compose up -d      # recreate on new pinned images
docker compose up -d --scale manager=3           # more API instances; the proxy round-robins `manager:8080`
# Never scale `executor`: exactly one must run.

Troubleshooting ​

Port conflicts ​

bash
sudo lsof -i :80      # or :443, :8080, :5432

Stop the conflicting process, or change the host side of the port mapping in docker-compose.yml ("8082:8080" publishes the Manager on 8082).

The Manager restarts in a loop ​

docker compose logs manager. invalid configuration: … on the first line names the setting; under PFY_ENV=production the usual ones are a missing db.password, a missing pocket_id.api_key (pass 1 of the bundle install), db.sslmode=disable, a missing PFY_PROXY_SECRET on an API-serving process, and a missing or malformed PFY_ENCRYPTION_KEY. config.yml: file not found means the image predates environment-only configuration; use the pinned bundle image.

Queries fail with relation "…" does not exist ​

The schema was never applied. The Manager does not migrate on boot in any run mode, and its image cannot run -migrate. Re-run the one-shot and read its log:

bash
docker compose up migrate          # idempotent: applies only pending files
docker compose logs migrate

Recreating everything (docker compose down -v && docker compose up -d) deletes all data; the one-shot runs again on the fresh database.

Everything returns 401 ​

PFY_PROXY_SECRET (Manager) and PFY_MANAGER_TOKEN (proxy) differ. The 401 body's error field says whether the Manager has no secret at all or received a wrong one. The bundle derives both from PFY_PROXY_SECRET in .env.

Containers cannot reach each other ​

bash
docker compose exec manager ping -c1 postgres
docker compose exec manager nc -zv postgres 5432
docker network inspect productify_default

Memory ​

docker stats shows usage. Add limits per service under deploy.resources.limits (see Resource Limits).

Clean restart ​

bash
docker compose down        # stop, keep volumes
docker compose down -v     # stop and DELETE all data (database, certificates, Pocket ID)
docker compose up -d

Backup & Recovery ​

Install bundle ​

Two stores hold state: the Manager database and Pocket ID's data directory (users, passkeys, OIDC clients). Run from deployments/compose:

bash
# Manager database: consistent logical dump
docker compose exec -T postgres pg_dump -U productify -Fc productify > manager-$(date +%F).dump

# Restore into a fresh/empty database
docker compose exec -T postgres pg_restore -U productify -d productify --clean --if-exists < manager-YYYY-MM-DD.dump

# Pocket ID data directory
docker compose exec -T pocket-id tar -C /app -cf - data > pocket-id-$(date +%F).tar

# Restore Pocket ID (stack stopped)
docker compose stop pocket-id
docker compose run --rm -T --no-deps pocket-id sh -c 'rm -rf /app/data && tar -C /app -xf -' < pocket-id-YYYY-MM-DD.tar
docker compose start pocket-id

Keep .env with the backups: POCKET_ID_ENCRYPTION_KEY is needed to read Pocket ID's data, PFY_ENCRYPTION_KEY to read the Manager's encrypted secret columns. .env is the only copy of your secrets.

Automated backup and restore-and-verify drills ​

The single-database sketch above is superseded by the versioned scripts in deployments/ops/backup/, which back up both databases (manager-db and Pocket ID) and verify the backups by restoring them:

  • backup.sh: pg_dump -Fc both databases to timestamped dumps, fails loudly on an empty/truncated dump, and prunes past RETENTION_DAYS (default 30).
  • restore-drill.sh: the drill. Restores each dump into a throwaway scratch Postgres container and asserts the expected tables exist, core tables are non-empty, and a smoke query succeeds. A corrupt dump fails the drill (non-zero exit). This is what turns a dump-and-forget cron into a backup you can trust.
  • backup.nomad: a periodic (daily 02:00 UTC) Nomad batch job running backup.sh; DB URLs come from Nomad variables, never committed.
bash
# Back up both databases.
MANAGER_DB_URL=postgres://productify:$PW@localhost:5432/productify \
POCKET_ID_DB_URL=postgres://pocketid:$PW@localhost:5432/pocketid \
  deployments/ops/backup/backup.sh

# Prove they restore (boots an ephemeral scratch Postgres, verifies, tears down).
deployments/ops/backup/restore-drill.sh

Pocket ID storage

The shipped compose runs Pocket ID on SQLite by default. To bring it under this unified pg_dump backup, point Pocket ID at Postgres via its DB_CONNECTION_STRING; otherwise back up its /app/data volume separately.

Production Checklist ​

  • [ ] Use the install bundle's pinned image tags (never latest)
  • [ ] ./install.sh --check-only passes; TLS_MODE and the Caddyfile agree
  • [ ] .env is chmod 600, backed up out-of-band, never committed
  • [ ] The productify-proxy OIDC client and the ADMIN_EMAIL user exist in Pocket ID; --make-admin succeeded
  • [ ] Backups of both stores run on a schedule and restore-drill.sh passes
  • [ ] Only 80/443 are reachable from the internet
  • [ ] Resource limits and log rotation configured
  • [ ] Prometheus + alerting wired to the shipped rules (deployments/ops/alerting/)
  • [ ] Exactly one executor

See Also ​