Self-hosting
Reverse proxy and tunnels
One origin for web and API, WebSockets included. Caddy, nginx and Cloudflare Tunnel.
AgentDock expects the web app and the API on one origin: the web app at /, the API under /api, WebSockets included. This keeps the session cookie first-party and gives runners a single address to pair with. This page describes what the proxy has to do, with Caddy and nginx examples, and the tunnel options.
What the proxy must do
| Path | Goes to | Notes |
|---|---|---|
/api/* | API, port 8180, with /api stripped | The API has no prefix of its own: /api/health must arrive as /health. |
/api/live | API /live (WebSocket) | Live updates for the browser. Origin must equal WEB_URL. |
/api/runner | API /runner (WebSocket) | The runner's connection, authenticated with its bearer token. |
/terminal | API /terminal (WebSocket), no prefix | Admin terminal attach. See the note below. |
| everything else | web app, port 3517 | Pages, static assets. |
The API process serves REST and all three WebSockets on the same port, so one upstream covers them. The proxy must pass the Upgrade and Connection headers and must not time out a quiet socket too early: the runner sends a heartbeat every 15 seconds, but the browser's live socket and an idle terminal can be quiet for longer.
The web app builds the terminal socket URL from NEXT_PUBLIC_LIVE_URL by replacing its whole path with /terminal. With NEXT_PUBLIC_LIVE_URL=wss://agentdock.example.com/api/live, the browser connects to wss://agentdock.example.com/terminal, without the /api prefix. Until that changes, route /terminal to the API as well, or terminal attach fails while everything else works.
Caddy
Caddy is the reference proxy. handle_path strips the matched prefix, and reverse_proxy passes WebSocket upgrades on its own. With a public hostname, Caddy also gets the TLS certificate.
agentdock.example.com {
handle_path /api/* {
reverse_proxy api:8180
}
handle /terminal* {
reverse_proxy api:8180
}
handle {
reverse_proxy web:3517
}
}Inside the Compose stack the upstreams are the service names api and web. On a host install, use 127.0.0.1:8180 and 127.0.0.1:3517.
nginx
This example terminates TLS in nginx on the host and reaches the containers through ports published on loopback. Publish them in the compose file with ports: ['127.0.0.1:8180:8180'] on api and ports: ['127.0.0.1:3517:3517'] on web, and drop the Caddy service.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name agentdock.example.com;
ssl_certificate /etc/letsencrypt/live/agentdock.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/agentdock.example.com/privkey.pem;
# GitHub webhook bodies can be up to 5 MB.
client_max_body_size 6m;
# The trailing slash on proxy_pass replaces /api/ with /, stripping the prefix.
location /api/ {
proxy_pass http://127.0.0.1:8180/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
proxy_buffering off;
}
# Terminal attach: same API, no prefix to strip.
location /terminal {
proxy_pass http://127.0.0.1:8180;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location / {
proxy_pass http://127.0.0.1:3517;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name agentdock.example.com;
return 301 https://$host$request_uri;
}The map block turns a request with an Upgrade header into Connection: upgrade and leaves plain requests alone. Without the two proxy_set_header lines for Upgrade and Connection, nginx drops the upgrade and every WebSocket fails, which shows up as Live updates paused in the UI and as runners that never come online.
This nginx config is derived from the routing rules above; the project's reference deployment uses Caddy. Test it with the checks at the end of this page.
TRUST_PROXY
The API uses the client IP for the per-IP login throttle, so it has to know which X-Forwarded-For to believe. TRUST_PROXY is passed to Express's trust proxy setting.
| Value | Use it when |
|---|---|
loopback (default) | The proxy runs on the same host and connects over 127.0.0.1. This is also the local development setup (the Next.js /api rewrite). |
uniquelocal | The proxy reaches the API over a private network: Caddy on the compose network, or nginx through ports Docker publishes (Docker forwards from a private bridge address). |
| an address, a CIDR or a list | You know the proxy's exact address, for example 10.0.0.5. |
| a number | Trust that many hops from the right of X-Forwarded-For. |
true / false | Trust every hop / none. Avoid true on anything reachable from outside. |
Cloudflare Tunnel
A tunnel publishes the origin without opening a port on the host. TLS ends at Cloudflare and cloudflared connects to your proxy over plain HTTP. This is how the project's reference deployment runs, and Docker Compose has the full setup.
The points that matter:
- The public hostname's service is
HTTPtoproxy:80, and Caddy runs withauto_https offon:80. - Caddy needs
trusted_proxies static private_rangesto keep the client IP thatcloudflaredforwards, and the API needsTRUST_PROXY=uniquelocal. - Cloudflare passes WebSockets through tunnels without extra settings.
Tailscale
Tailscale gives two options, both standard Tailscale features rather than anything AgentDock ships.
- Tailscale Serve publishes the origin only inside your tailnet. Runners and browsers must be on the tailnet. Nothing is public. This is a good fit for a home server.
- Tailscale Funnel publishes it to the internet on your
*.ts.netname. Use it only if you need GitHub or a third party to reach AgentDock.
In both cases Tailscale terminates TLS, so run Caddy with auto_https off on :80 as in the tunnel Caddyfile, publish the proxy on loopback (127.0.0.1:80:80), and point Serve or Funnel at http://127.0.0.1:80. Set WEB_URL, API_URL and the web image's NEXT_PUBLIC_LIVE_URL to the https://<machine>.<tailnet>.ts.net name.
What must be public
Less than you might think. Runners dial out, Telegram uses long polling, and the GitHub App registration only needs your browser to reach AgentDock. So the whole origin can stay on a private network or a tailnet.
Only two paths ever need to be reachable from the internet, and both are optional:
| Path | Needed for |
|---|---|
POST /api/hooks/github | The GitHub App webhook. Without it, the App's hook stays inactive and runners keep polling GitHub every 60 seconds. Nothing breaks. |
POST /api/hooks/<publicId> | Inbound triggers, signed with HMAC-SHA256. |
Both are under /api/hooks/, so you can expose only that prefix (for example through a separate tunnel route) and keep the UI private. Set PUBLIC_URL to the public base that GitHub reaches, for example https://agentdock.example.com/api.
Check your proxy
# REST through the prefix: expect {"status":"ok",...}
curl https://agentdock.example.com/api/health
# WebSocket upgrade on the runner path: expect 101 or a 4xx from the API, never 404 from the web app
curl -i -N --http1.1 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
https://agentdock.example.com/api/runnerThen sign in: the live-status dot in the top bar should stay connected, and a paired runner should show as online on Admin → Runners.