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 versionshould 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 —
localhostworks 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:
| Variable | What to set |
|---|---|
SECRET_KEY | The hex from step 2. |
ENCRYPTION_KEY | The base64 from step 2. |
POSTGRES_PASSWORD | A strong password for the bundled Postgres. |
FIRST_ORG_NAME | The org name shown in the UI (e.g. Acme Inc). |
FIRST_ADMIN_EMAIL | The first admin's login. |
FIRST_ADMIN_PASSWORD | 12+ characters. Rotate it via the UI right after first login. |
CORS_ALLOWED_ORIGINS | Your public origin(s), e.g. https://dq.example.com. |
COOKIE_SECURE | Leave as true unless you're on plain-HTTP localhost. |
DEPLOYMENT_REGION | A label that matches reality (e.g. eu-west-1 or self-hosted). |
If you want Caddy to terminate TLS:
| Variable | What to set |
|---|---|
DQC_PUBLIC_HOST | The DNS name pointing at this host (e.g. dq.example.com). |
DQC_ACME_EMAIL | Email 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:
postgresandrediscome up and pass their healthchecks.migraterunsalembic upgrade headagainst Postgres and exits.seedrunsscripts/seed_first_admin.py— idempotent, creates the first org / admin / workspace / tenant schema.api,worker,uicome up.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
https://dq.example.com/healthz(orhttp://<host>:8080/healthzwithout Caddy) returnsok.- The UI renders the login page at the same URL.
- Logging in with
FIRST_ADMIN_EMAIL/FIRST_ADMIN_PASSWORDlands you on an empty dashboard for the org you named inFIRST_ORG_NAME. docker compose -f infra/docker-compose.prod.yml psshows every service inhealthy(orrunningfor the one-shotmigrate/seedcontainers, which exited with status 0).- 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=Nworks, 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
| Goal | Use |
|---|---|
| Production self-host on one host | infra/docker-compose.prod.yml (this page). |
| Local development with hot reload | infra/docker-compose-dev.yml for the databases, run api/worker/ui on the host. See Self-host. |
| Local all-in-one for trying the product | make compose-up (infra/docker-compose.yml) — dev-only literal secrets. |
| Kubernetes / ECS / Cloud Run | The Dockerfiles + the env table — see Deploy to production. |
Troubleshooting
migratecontainer exits non-zero. Taildocker compose logs migrate. The most common cause isENCRYPTION_KEYnot decoding to 32 bytes — the Settings validator runs before Alembic even sees the database.seedsays it skipped. EitherFIRST_ORG_NAME/FIRST_ADMIN_EMAIL/FIRST_ADMIN_PASSWORDare 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 forDQC_PUBLIC_HOSTisn't pointing at this host yet, or :80/:443 aren't reachable from the public internet. Let's Encrypt needs to reach:80for the HTTP-01 challenge. - Login works but every page 500s. Check the API logs — usually
DB_URLis reachable from the api container but the schema's empty, which meansmigratewas skipped or failed silently. Re-rundocker 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, confirmDQ_AGENT_CLOUD_URLin.envmatches what the agent can actually reach (http://api:8000over the compose network, not the public DNS name, since the agent shares the network with the API).