Skip to main content

Self-host with Docker Compose

Goal

Run the full DQ Cloud stack — Postgres, Redis, the API, the worker, the UI, and an optional TLS edge — on a single host with one docker compose up and a populated .env. By the end you'll be able to log in as the admin user you defined in .env and run your first checkpoint.

This is the right path when you want a self-contained deployment on one VM (a t3.medium-class box handles a small team comfortably). For Kubernetes, ECS, or Cloud Run, see Deploy to production — same env vars, different orchestrator.

Prereqs

  • A host with Docker Engine 24+ and the Compose v2 plugin (docker compose version should report 2.x).
  • 4 GB RAM free for the bundled Postgres + Redis + the application containers. 8 GB is comfortable.
  • A DNS A record pointing at the host if you want automatic TLS (optional — localhost works for laptop testing).

Steps

1. Clone the repo and copy the env template

git clone https://github.com/your-org/dq-cloud.git
cd dq-cloud
cp .env.example .env

The .env.example is annotated for every variable. The block at the bottom (docker-compose-only:) configures the bundled Postgres, the image registry, the optional Caddy edge, and the first-run admin seed — those are the ones most operators touch first.

2. Generate the two secrets that must be unique per deployment

# JWT signing key (HS256). Replace SECRET_KEY in .env.
openssl rand -hex 32

# AES-256-GCM key for datasource / LLM credential encryption.
# Replace ENCRYPTION_KEY in .env. Must decode to exactly 32 bytes —
# DQ Cloud refuses to start otherwise.
python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"

Store both in your secret manager too — losing ENCRYPTION_KEY means losing every encrypted datasource credential and LLM key in the database, with no recovery path. See Rotate a secret key for the rotation flow.

3. Fill in the rest of the required values

At minimum:

VariableWhat to set
SECRET_KEYThe hex from step 2.
ENCRYPTION_KEYThe base64 from step 2.
POSTGRES_PASSWORDA strong password for the bundled Postgres.
FIRST_ORG_NAMEThe org name shown in the UI (e.g. Acme Inc).
FIRST_ADMIN_EMAILThe first admin's login.
FIRST_ADMIN_PASSWORD12+ characters. Rotate it via the UI right after first login.
CORS_ALLOWED_ORIGINSYour public origin(s), e.g. https://dq.example.com.
COOKIE_SECURELeave as true unless you're on plain-HTTP localhost.
DEPLOYMENT_REGIONA label that matches reality (e.g. eu-west-1 or self-hosted).

If you want Caddy to terminate TLS:

VariableWhat to set
DQC_PUBLIC_HOSTThe DNS name pointing at this host (e.g. dq.example.com).
DQC_ACME_EMAILEmail for Let's Encrypt expiry notifications.

4. Bring the stack up

Without the TLS edge (rely on your own load balancer / ingress in front, which then talks to the host on port 8080):

docker compose -f infra/docker-compose.prod.yml --env-file .env up -d

With the bundled Caddy TLS edge (Caddy auto-provisions a Let's Encrypt cert for DQC_PUBLIC_HOST and binds :80 + :443 on the host):

docker compose -f infra/docker-compose.prod.yml --env-file .env --profile edge up -d

Compose orchestrates startup in order:

  1. postgres and redis come up and pass their healthchecks.
  2. migrate runs alembic upgrade head against Postgres and exits.
  3. seed runs scripts/seed_first_admin.py — idempotent, creates the first org / admin / workspace / tenant schema.
  4. api, worker, ui come up.
  5. caddy (under --profile edge) comes up last, after the UI is healthy.

docker compose ps shows the per-service status; docker compose logs -f api tails the API logs.

5. (Optional) Bring up the on-prem agent

If you've issued an agent token in the UI (Settings → Agents) and want to run jobs against a datasource that lives outside this host, drop the token into .env and bring the agent up with its profile:

DQ_AGENT_TOKEN=dqa_... \
docker compose -f infra/docker-compose.prod.yml --env-file .env --profile agent up -d agent

Full agent guide: Deploy the agent.

Verify

  1. https://dq.example.com/healthz (or http://<host>:8080/healthz without Caddy) returns ok.
  2. The UI renders the login page at the same URL.
  3. Logging in with FIRST_ADMIN_EMAIL / FIRST_ADMIN_PASSWORD lands you on an empty dashboard for the org you named in FIRST_ORG_NAME.
  4. docker compose -f infra/docker-compose.prod.yml ps shows every service in healthy (or running for the one-shot migrate / seed containers, which exited with status 0).
  5. The footer shows Region: <DEPLOYMENT_REGION>.

Maintenance

Updating to a new release

Pin a specific IMAGE_TAG (e.g. sha-abc1234 or v1.4.0) in .env, then:

docker compose -f infra/docker-compose.prod.yml --env-file .env pull
docker compose -f infra/docker-compose.prod.yml --env-file .env up -d

migrate runs again on the new image and applies any pending public-schema migrations. Tenant migrations apply lazily — see Deploy to production for the dqc migrate-all-tenants command if you want them forced at deploy time.

Backing up Postgres

The bundled Postgres writes to the named volume dqcloud_postgres_data. The minimum viable backup is a nightly pg_dump cron from another host:

docker compose -f infra/docker-compose.prod.yml exec -T postgres \
pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > dqcloud-$(date +%F).sql.gz

For production, prefer managed Postgres with point-in-time recovery — the per-tenant data lives only in this database, so losing it loses customer state.

Scaling

  • Worker: docker compose -f infra/docker-compose.prod.yml --env-file .env up -d --scale worker=N. ARQ pulls jobs from Redis with BLPOP, so replicas share the queue with no coordination.
  • API: --scale api=N works, but a single-host Compose deploy with replicas only helps when CPU is the bottleneck. Once you outgrow the host, move to Kubernetes / ECS — same images, same env, different orchestrator.

Stopping cleanly

# Stop, keep data
docker compose -f infra/docker-compose.prod.yml down

# Stop + wipe volumes (DESTROYS Postgres + Redis state — be deliberate)
docker compose -f infra/docker-compose.prod.yml down -v

When to use a different file

GoalUse
Production self-host on one hostinfra/docker-compose.prod.yml (this page).
Local development with hot reloadinfra/docker-compose-dev.yml for the databases, run api/worker/ui on the host. See Self-host.
Local all-in-one for trying the productmake compose-up (infra/docker-compose.yml) — dev-only literal secrets.
Kubernetes / ECS / Cloud RunThe Dockerfiles + the env table — see Deploy to production.

Troubleshooting

  • migrate container exits non-zero. Tail docker compose logs migrate. The most common cause is ENCRYPTION_KEY not decoding to 32 bytes — the Settings validator runs before Alembic even sees the database.
  • seed says it skipped. Either FIRST_ORG_NAME / FIRST_ADMIN_EMAIL / FIRST_ADMIN_PASSWORD are not all set, or the admin email already exists in the database. Both are intentional no-ops.
  • Caddy reports acme: error presenting token. Your DNS A record for DQC_PUBLIC_HOST isn't pointing at this host yet, or :80/:443 aren't reachable from the public internet. Let's Encrypt needs to reach :80 for the HTTP-01 challenge.
  • Login works but every page 500s. Check the API logs — usually DB_URL is reachable from the api container but the schema's empty, which means migrate was skipped or failed silently. Re-run docker compose -f infra/docker-compose.prod.yml run --rm migrate.
  • A dqa_ token from the UI doesn't work in the agent profile. Token cache TTL is 5s — wait a beat, then retry. If it still fails, confirm DQ_AGENT_CLOUD_URL in .env matches what the agent can actually reach (http://api:8000 over the compose network, not the public DNS name, since the agent shares the network with the API).