Self-hosting
Docker Compose
The reference production stack: PostgreSQL, Redis, API, web and a proxy.
This page sets up a production-style AgentDock control plane with Docker Compose: PostgreSQL, Redis, the API, the web app and Caddy as the reverse proxy, with an optional Cloudflare tunnel. It is a generic version of the project's own reference deployment.
There is no one-command installer and no published Docker image yet. You clone the repository and build the api and web images on your host. The web image bakes your public URL in at build time, so it is specific to your hostname.
Layout
The stack lives in its own directory, separate from the git checkout. This page uses ~/stacks/agentdock and the hostname agentdock.example.com.
docker-compose.yml
Caddyfile
.env POSTGRES_PASSWORD, REDIS_PASSWORD (compose interpolation)
env/api.env API settings and APP_ENCRYPTION_KEY
env/cloudflared.env TUNNEL_TOKEN (only with the tunnel)Traffic flows like this. Only the proxy is reachable from outside; everything else stays on the compose network.
browser, runners ──HTTPS──▶ proxy (Caddy) ─┬─ /api/* ─▶ api:8180 ─▶ postgres, redis
└─ /* ─▶ web:3517Build the images
Clone the repository and build both images from its root. The tag local is only a convention.
git clone https://github.com/AndreyBegma/AgentDock ~/src/AgentDock
cd ~/src/AgentDock
docker build -t agentdock-api:local -f apps/api/Dockerfile .
docker build -t agentdock-web:local -f apps/web/Dockerfile \
--build-arg NEXT_PUBLIC_LIVE_URL=wss://agentdock.example.com/api/live \
--build-arg API_URL=http://api:8180 \
.The web image takes two build arguments:
| Build argument | Value | Why it is needed at build time |
|---|---|---|
NEXT_PUBLIC_LIVE_URL | wss://<host>/api/live | Required. The browser opens the live-updates WebSocket at this URL, and Next.js bakes NEXT_PUBLIC_* values into the client bundle. |
API_URL | http://api:8180 | The target of the web app's /api rewrite, which Next.js also fixes at build time. Defaults to http://api:8180. |
Changing your hostname means rebuilding the web image. The API image has no build arguments.
Generate the secrets
Create the directory and the two env files. The passwords are pure randomness; nobody types them.
mkdir -p ~/stacks/agentdock/env && cd ~/stacks/agentdock
umask 077
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
REDIS_PASSWORD=$(openssl rand -hex 24)
EOF
cat > env/api.env <<EOF
PORT=8180
APP_ENV=production
API_URL=https://agentdock.example.com/api
WEB_URL=https://agentdock.example.com
APP_URL=https://agentdock.example.com
PUBLIC_URL=https://agentdock.example.com/api
TRUST_PROXY=uniquelocal
APP_ENCRYPTION_KEY=$(openssl rand -base64 32)
EOFWhat these mean:
APP_ENV=productionmarks the session and CSRF cookiesSecure. Serve the app over HTTPS.API_URLis the address runners pair with. Behind this proxy it is the public origin plus/api.WEB_URLis the only allowed CORS and WebSocketOrigin. It must be exactly the URL in the browser's address bar.PUBLIC_URLis the base GitHub calls for the optional GitHub App webhook (<PUBLIC_URL>/hooks/github). Leave it out if you will not use the App.TRUST_PROXY=uniquelocaltrustsX-Forwarded-Forfrom private addresses, which is where Caddy sits on the compose network. The login rate limit uses the client IP.APP_ENCRYPTION_KEY(32 random bytes, base64) seals stored secrets: the Telegram bot token, webhook and trigger secrets, the GitHub App key. Back it up separately from the database.
DATABASE_URL and REDIS_URL are built in the compose file from .env. Every other variable is in the configuration reference.
The compose file
name: agentdock
x-logging: &logging
driver: json-file
options:
max-size: 20m
max-file: '5'
services:
postgres:
image: postgres:16.5-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env}
POSTGRES_DB: agentdock
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 10
mem_limit: 512m
logging: *logging
# BullMQ backend for cron schedules. AOF keeps pending timers over a restart;
# BullMQ requires noeviction.
redis:
image: redis:7.4.11-alpine
restart: unless-stopped
environment:
REDISCLI_AUTH: ${REDIS_PASSWORD:?set it in .env}
command: >
redis-server
--requirepass ${REDIS_PASSWORD:?set it in .env}
--appendonly yes
--appendfsync everysec
--maxmemory-policy noeviction
volumes:
- redis:/data
healthcheck:
test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG']
interval: 5s
timeout: 5s
retries: 10
mem_limit: 256m
logging: *logging
# Applies migrations and the seed in its entrypoint, then listens on 8180.
api:
image: agentdock-api:local
restart: unless-stopped
env_file: ./env/api.env
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/agentdock?schema=public
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
mem_limit: 1g
logging: *logging
web:
image: agentdock-web:local
restart: unless-stopped
environment:
# Read at runtime by server components, not only by the build-time rewrite.
API_URL: http://api:8180
depends_on:
api: { condition: service_healthy }
mem_limit: 512m
logging: *logging
proxy:
image: caddy:2.10.2-alpine
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
api: { condition: service_healthy }
web: { condition: service_started }
mem_limit: 128m
logging: *logging
volumes:
pgdata:
redis:
caddy_data:The api and web images carry their own Docker HEALTHCHECKs, which is why depends_on can wait for service_healthy:
| Service | Check |
|---|---|
api | GET http://127.0.0.1:8180/health answers 2xx, every 15 s after a 60 s start period. |
web | GET http://127.0.0.1:3517/ answers below 500 (signed-out pages redirect). |
postgres | pg_isready -U postgres |
redis | redis-cli ping returns PONG |
The Caddyfile
Caddy gets a certificate for your hostname automatically (ports 80 and 443 must reach it, and DNS must point at the host). It strips the /api prefix before the API sees the request; the API has no prefix of its own.
agentdock.example.com {
# API: /api/health, /api/runners/pair, and the WebSockets
# /api/live and /api/runner. The prefix is stripped.
handle_path /api/* {
reverse_proxy api:8180
}
# Terminal attach opens its socket at /terminal (see Reverse proxy).
handle /terminal* {
reverse_proxy api:8180
}
handle {
reverse_proxy web:3517
}
}Caddy passes WebSocket upgrades through reverse_proxy without extra settings. The Reverse proxy page explains the /terminal route and has an nginx version.
Start the stack
cd ~/stacks/agentdock
docker compose up -d
docker compose psThe first start takes a while: the API applies every migration and seeds the price table before it reports healthy. If a migration fails, the API stays unhealthy and the web app and proxy do not start. Read the log with docker compose logs api.
Check it from outside:
curl https://agentdock.example.com/api/health
# {"status":"ok","timestamp":"..."}Create the first admin
The seed creates no users, and public registration starts closed. Create the admin inside the API container:
docker compose exec api node dist/src/cli/admin-create.jsIt prompts for an email and a password. Then open https://agentdock.example.com and sign in.
Pair runners
Pair each agent machine against the API through the proxy, so the server is your origin plus /api:
agentdock-runner pair --server https://agentdock.example.com/api --code XXXX-XXXXGet the code from Admin → Runners → Add runner. The dialog builds this exact command for you. See Install and pair.
A common setup puts the control plane and a runner on one server. Install the runner on the host as a normal user service, not in a container, so it sees the same tmux server, worktrees and agent configs as you do.
Optional: Cloudflare Tunnel
With a tunnel, nothing is published on the host and TLS ends at Cloudflare. This is how the project's own reference deployment runs.
- In Cloudflare Zero Trust, create a tunnel and add a public hostname,
agentdock.example.com, with service typeHTTPand URLproxy:80. - Put its token in
env/cloudflared.envasTUNNEL_TOKEN=...(mode600). - Remove the
ports:andcaddy_datalines fromproxy, add the service below, and use the tunnel Caddyfile.
cloudflared:
image: cloudflare/cloudflared:2025.9.1
restart: unless-stopped
command: tunnel --no-autoupdate run
env_file: ./env/cloudflared.env
depends_on:
proxy: { condition: service_started }
mem_limit: 128m
logging: *logging{
auto_https off
admin off
servers {
# Keep cloudflared's X-Forwarded-For and -Proto.
trusted_proxies static private_ranges
}
}
:80 {
handle_path /api/* {
reverse_proxy api:8180
}
handle /terminal* {
reverse_proxy api:8180
}
handle {
reverse_proxy web:3517
}
}An HTTP 530 (Cloudflare error 1033) from the public URL means the hostname exists but no connector is running. Check TUNNEL_TOKEN and docker compose logs cloudflared.