Skip to content
AgentDock

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.

Layout

The stack lives in its own directory, separate from the git checkout. This page uses ~/stacks/agentdock and the hostname agentdock.example.com.

~/stacks/agentdock
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.

Request flow
browser, runners ──HTTPS──▶ proxy (Caddy) ─┬─ /api/*  ─▶ api:8180 ─▶ postgres, redis
                                           └─ /*      ─▶ web:3517

Build the images

Clone the repository and build both images from its root. The tag local is only a convention.

Terminal
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 argumentValueWhy it is needed at build time
NEXT_PUBLIC_LIVE_URLwss://<host>/api/liveRequired. The browser opens the live-updates WebSocket at this URL, and Next.js bakes NEXT_PUBLIC_* values into the client bundle.
API_URLhttp://api:8180The 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.

Terminal
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)
EOF

What these mean:

  • APP_ENV=production marks the session and CSRF cookies Secure. Serve the app over HTTPS.
  • API_URL is the address runners pair with. Behind this proxy it is the public origin plus /api.
  • WEB_URL is the only allowed CORS and WebSocket Origin. It must be exactly the URL in the browser's address bar.
  • PUBLIC_URL is 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=uniquelocal trusts X-Forwarded-For from 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

docker-compose.yml
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:

ServiceCheck
apiGET http://127.0.0.1:8180/health answers 2xx, every 15 s after a 60 s start period.
webGET http://127.0.0.1:3517/ answers below 500 (signed-out pages redirect).
postgrespg_isready -U postgres
redisredis-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.

Caddyfile
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

Terminal
cd ~/stacks/agentdock
docker compose up -d
docker compose ps

The 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:

Terminal
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:

Terminal
docker compose exec api node dist/src/cli/admin-create.js

It 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:

Terminal
agentdock-runner pair --server https://agentdock.example.com/api --code XXXX-XXXX

Get the code from Admin → Runners → Add runner. The dialog builds this exact command for you. See Install and pair.

A runner on the same host

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.

  1. In Cloudflare Zero Trust, create a tunnel and add a public hostname, agentdock.example.com, with service type HTTP and URL proxy:80.
  2. Put its token in env/cloudflared.env as TUNNEL_TOKEN=... (mode 600).
  3. Remove the ports: and caddy_data lines from proxy, add the service below, and use the tunnel Caddyfile.
docker-compose.yml (add)
  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
Caddyfile (tunnel)
{
	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.

Next steps