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 legacydocker-composebinary). - 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.
docker --version # Docker version 20.10.x or higher
docker compose version # Docker Compose version 2.x.x
docker ps # must not errorChoosing a stack
| You want to | Use | Section |
|---|---|---|
| Install Productify for real on one box (proxy, Pocket ID login, TLS) | deployments/compose/ install bundle | Self-hosted install bundle |
| Try the Manager API/UI with no login, no proxy | The evaluation stack below | Evaluation stack |
| Develop the Manager or proxy from source | Each repo's own docker-compose.yml | Development 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.
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:
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.
| Service | Image | Role |
|---|---|---|
manager | ghcr.io/productifyfw/manager:2.0.0 | Manager API + UI (PFY_RUN_MODE=api) |
executor | ghcr.io/productifyfw/manager:2.0.0 | Trigger and sweep executor (PFY_RUN_MODE=executor) |
postgres | postgres:16.4 | Manager database, serving TLS |
postgres-certs | postgres:16.4 | One-shot: generates the database's self-signed certificate |
migrate | arigaio/atlas:1.3.3-community-alpine | One-shot: applies ./migrations; manager waits on it |
pocket-id | ghcr.io/pocket-id/pocket-id:v2.14.0 | OIDC identity provider, served at https://<ID_DOMAIN> through the proxy |
proxy | ghcr.io/productifyfw/proxy:1.1.0 | Caddy + caddy-security + the productify plugin; the only service publishing ports (80, 443) |
example-app | ghcr.io/productifyfw/example-app:0.1.0 | Optional demo backend, profile example |
example-frontend | ghcr.io/productifyfw/example-frontend:0.1.0 | Optional demo SPA, profile example |
| File | What it is |
|---|---|
docker-compose.yml | The stack, images pinned. Never edit tags here; take a newer bundle instead. |
Caddyfile | The 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.example | Every install value and the secret slots install.sh fills. Copy to .env. |
install.sh | Preflight, secret generation, compose up, post-boot verification, --make-admin. |
install_test.sh | Tests 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.md | Compatibility matrix (bundle, Manager, proxy, Pocket ID, Postgres, CLI versions), upgrade path, backup/restore. |
Install
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 stackThree 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:
- Open
https://<ID_DOMAIN>and complete Pocket ID's first-run setup. - Create an API key (see Generate API Key for Manager) and put it in
.envasPOCKET_ID_API_KEY=…. - Register the OIDC client the proxy uses: client id
productify-proxy, client secret =OIDC_CLIENT_SECRETfrom.env. - Create the user named by
ADMIN_EMAIL.
./install.sh # pass 2: the Manager now starts and is verifiedThe 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:
| Check | What 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 HTML | The 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:
- Sign in once at
https://<MANAGER_DOMAIN>as theADMIN_EMAILuser. ./install.sh --make-adminsets the role forADMIN_EMAILand 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
migrateone-shot (arigaio/atlas) applies the vendored./migrationsbeforemanagerstarts (depends_on: condition: service_completed_successfully).install.shpins theatlas.sumhash, so a tampered or mismatched migration set is refused in preflight. - TLS to the database.
postgres-certsgenerates a self-signed certificate once; Postgres runs withssl=onand the Manager connects withPFY_DB_SSLMODE=require(the Manager refusesdisablein production). - Pocket ID behind the proxy. The Caddyfile serves
https://<ID_DOMAIN>and reverse-proxies it topocket-id:1411; Pocket ID runs withTRUST_PROXYso 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/*(thepfy logindevice-code flow),/pilet-feed/*and/language-packs/*ahead of the portal, because those callers cannot hold a portal session. Everything else goes throughauthorize with pocketpolicytomanager:8080with the proxy secret attached. - Proxy vhost sync is on:
managergetsPFY_PROXY_ADMIN_URL=http://proxy:2019,PFY_PROXY_PRELUDE_FILE=/etc/caddy/Caddyfile,PFY_PROXY_MANAGER_UPSTREAM=manager:8080andPFY_PROXY_METRICS_URL=http://proxy:2112, so onboarding an application gives it a working hostname without editing the Caddyfile. SetPFY_PROXY_ADMIN_URL=(empty) in.envto 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 -dafter install startsexample-appandexample-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 | .env | Caddyfile global block |
|---|---|---|
| Public domains, Let's Encrypt (default) | TLS_MODE=acme, real ACME_EMAIL | email {env.ACME_EMAIL} active |
| Internal / air-gapped, Caddy's own CA | TLS_MODE=internal | comment out email, uncomment local_certs; distribute Caddy's root (caddy_data volume, pki/authorities/local/root.crt) |
| Internal, bring-your-own certificates | TLS_MODE=internal | comment 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:
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_healthyGate 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:
- Open Pocket ID's first-run setup page:
https://<ID_DOMAIN>/setupon the install bundle,http://pocketid.localhost/setupon the development stacks - Create an administrator account:
- Username
- Email address
- Password
- Configure email server (optional but recommended for production)
- Set up Passkey (optional, enhances security)
2. Generate API Key for Manager
The manager needs an API key to communicate with Pocket ID:
- Log in to Pocket ID (
https://<ID_DOMAIN>, orhttp://pocketid.localhoston a development stack) - Go to Settings > Admin > API Keys
- Click "Create New API Key"
- Enter a name (e.g., "Manager Service")
- Copy the generated key (it will only be shown once!)
- Give it to the Manager as
PFY_POCKET_ID_API_KEY:
# .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-hereThen 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:
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-hereDo 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:
- Go to Settings > Admin > OIDC Clients
- Click "Create New Client"
- Fill in the details:
- Client ID:
productify-proxy(the install bundle's Caddyfile hard-codes this id; the development Caddyfiles usedev-oauth-client-id) - Redirect URIs:
https://<MANAGER_DOMAIN>/auth/oauth2/generic/authorization-code-callbackand one per app hostname (http://*.localhost/auth/oauth2/generic/authorization-code-callbackon a development stack) - Scopes:
openid,email,profile - Grant Types: Authorization Code
- Client ID:
- Save. On the install bundle set the client secret to the value of
OIDC_CLIENT_SECRETin.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.envasPFY_OAUTH_CLIENT_SECRET.
The Caddyfile never holds the secret itself:
{
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:
# 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 directlySecurity 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
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:
# Generate a random database password
openssl rand -hex 32The 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
services:
manager:
image: ghcr.io/productifyfw/manager:2.0.0
deploy:
resources:
limits:
cpus: "2"
memory: 2G
reservations:
cpus: "0.5"
memory: 512MLogging
services:
manager:
image: ghcr.io/productifyfw/manager:2.0.0
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"Custom Networks
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
backend:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.21.0.0/16Monitoring
Prometheus
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:
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
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.
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 productifyLogs
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 failedUpdates and scaling
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
sudo lsof -i :80 # or :443, :8080, :5432Stop 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:
docker compose up migrate # idempotent: applies only pending files
docker compose logs migrateRecreating 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
docker compose exec manager ping -c1 postgres
docker compose exec manager nc -zv postgres 5432
docker network inspect productify_defaultMemory
docker stats shows usage. Add limits per service under deploy.resources.limits (see Resource Limits).
Clean restart
docker compose down # stop, keep volumes
docker compose down -v # stop and DELETE all data (database, certificates, Pocket ID)
docker compose up -dBackup & Recovery
Install bundle
Two stores hold state: the Manager database and Pocket ID's data directory (users, passkeys, OIDC clients). Run from deployments/compose:
# 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-idKeep .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 -Fcboth databases to timestamped dumps, fails loudly on an empty/truncated dump, and prunes pastRETENTION_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 runningbackup.sh; DB URLs come from Nomad variables, never committed.
# 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.shPocket 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-onlypasses;TLS_MODEand the Caddyfile agree - [ ]
.envischmod 600, backed up out-of-band, never committed - [ ] The
productify-proxyOIDC client and theADMIN_EMAILuser exist in Pocket ID;--make-adminsucceeded - [ ] Backups of both stores run on a schedule and
restore-drill.shpasses - [ ] 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
- Configuration Reference: every
PFY_*variable - Manager Deployment
- Proxy Deployment
- Nomad Deployment: the scale-up path from this bundle
- Incident Runbook