Skip to content
AgentDock

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

PathGoes toNotes
/api/*API, port 8180, with /api strippedThe API has no prefix of its own: /api/health must arrive as /health.
/api/liveAPI /live (WebSocket)Live updates for the browser. Origin must equal WEB_URL.
/api/runnerAPI /runner (WebSocket)The runner's connection, authenticated with its bearer token.
/terminalAPI /terminal (WebSocket), no prefixAdmin terminal attach. See the note below.
everything elseweb app, port 3517Pages, 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.

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.

Caddyfile
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.

/etc/nginx/conf.d/agentdock.conf
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.

ValueUse 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).
uniquelocalThe 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 listYou know the proxy's exact address, for example 10.0.0.5.
a numberTrust that many hops from the right of X-Forwarded-For.
true / falseTrust 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 HTTP to proxy:80, and Caddy runs with auto_https off on :80.
  • Caddy needs trusted_proxies static private_ranges to keep the client IP that cloudflared forwards, and the API needs TRUST_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.net name. 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:

PathNeeded for
POST /api/hooks/githubThe 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

Terminal
# 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/runner

Then sign in: the live-status dot in the top bar should stay connected, and a paired runner should show as online on Admin → Runners.

Next steps