Repository navigation
Self Hosting
Nautelle's control plane is four services: the API (NestJS), the web app (Next.js), PostgreSQL and Redis. It can run anywhere your runners can reach over HTTPS — runners dial out, so the machines that hold your code need no open port. There is no one-command production installer yet; this page describes the reference deployment Nautelle's own staging runs on, and every environment variable the services read.
flowchart LR
cf["Cloudflare<br/>(Access + Tunnel)"] --> cfd["cloudflared"]
cfd --> caddy["Caddy :80<br/>one origin"]
caddy -->|"/api/* (prefix stripped,<br/>WebSockets included)"| api["API :8180"]
caddy -->|"everything else"| web["Web :3517"]
api --> pg[("PostgreSQL 16")]
api --> redis[("Redis 7.4<br/>schedule timers")]
api --- att[("attachments volume")]
runner["nautelle-runner<br/>(your machines)"] -->|"wss://host/api/runner"| caddy
The recipe lives in deploy/ and deploy/README.md:
-
Docker Compose runs PostgreSQL, Redis, the API, the web app, Caddy and cloudflared on a private network (
docker-compose.yml). Nothing is published on the host. -
Caddy gives everything one origin:
/api/*goes to the API with the prefix stripped, WebSockets included, and everything else to the web app (Caddyfile). Cookies stay first-party. It also caps request bodies at 10 MB and sets security headers. - A Cloudflare Tunnel publishes that origin; Cloudflare Access can sit in front of it.
-
The API migrates and seeds in its entrypoint (
prisma migrate deploy, then the idempotent seed) and refuses to start if either fails. Deploying is replacing containers.
From the repository root:
docker build --pull -t agentdock-api -f apps/api/Dockerfile .
docker build --pull -t agentdock-web -f apps/web/Dockerfile \
--build-arg NEXT_PUBLIC_LIVE_URL=wss://dock.example.com/api/live \
--build-arg API_URL=http://api:8180 .NEXT_PUBLIC_LIVE_URL and API_URL are baked into the web build; changing either means rebuilding the web image.
Copy the examples and fill them in. The reference stack uses .env for the compose passwords and env/api.env, env/cloudflared.env for the services (deploy/staging/env/). Generate random secrets on the host — never commit them:
openssl rand -base64 32 # APP_ENCRYPTION_KEY, LLM_SECRETS_KEYKeep every secret file at mode 600.
docker compose up -dCheck https://<host>/api/health.
The seed creates no users. Run the admin CLI inside the API container:
docker exec -it <api-container> node dist/src/cli/admin-create.jsIn the web app, Admin › Runners › Add runner, then on each machine:
nautelle-runner pair --server https://<host>/api --code XXXX-XXXX --allow-root ~/devBehind Cloudflare Access, give the runner a service token with --access-client-id and --access-client-secret-stdin. Let the service token through on /api/runner and /api/runners/pair, and leave /api/hooks/* open — inbound webhooks and Claude Code hooks authenticate with their own signatures and tokens. See Installing the Runner.
| Variable | Required | Purpose |
|---|---|---|
PORT |
yes | API port, 8180
|
APP_ENV |
yes |
production marks session and CSRF cookies Secure |
API_URL · WEB_URL
|
yes | public origins of the API and the web app (CORS uses WEB_URL) |
APP_URL |
— | public URL of the web app, for Telegram links and the Telegram Mini App (needs https) |
TRUST_PROXY |
— | whose X-Forwarded-For to believe for the client IP; loopback by default |
DATABASE_URL |
yes | PostgreSQL connection string |
REDIS_URL |
yes¹ | Redis for schedule timers |
SCHEDULER_ENABLED |
— |
false runs no schedules worker and needs no Redis. Default true
|
QUEUE_PREFIX |
— | key prefix of every queue. Default agentdock
|
APP_ENCRYPTION_KEY |
for integrations | seals webhook secrets, the Telegram bot token and the GitHub App's secrets (32 bytes, base64) |
LLM_SECRETS_KEY |
for AI features | seals AI provider keys; without it the assistant does not start, the rest of the API runs |
PUBLIC_URL |
for the GitHub App | public HTTPS base GitHub can reach for POST /hooks/github
|
GITHUB_API_BASE |
— | GitHub REST base. Default https://api.github.com
|
DEEPL_API_KEY |
— | translate saved text with DeepL; empty uses a small model on the runner |
SPEECH_TO_TEXT_URL · SPEECH_TO_TEXT_API_KEY · SPEECH_TO_TEXT_MODEL
|
— | a Whisper-compatible service for Telegram voice input. Model default whisper-1
|
ATTACHMENTS_DIR |
— | where card attachments are stored. Default ./data/attachments
|
ATTACHMENTS_PROJECT_QUOTA_BYTES |
— | storage per project. Default 1 GiB |
APP_TIMEZONE |
— | default time zone for new budgets. Default UTC
|
AUDIT_VERIFY_CRON |
— | when the audit chain is verified. Default 0 3 * * *
|
USAGE_RECOMPUTE_BATCH |
— | requests re-priced per transaction. Default 1000
|
ACTIVITY_POLL_MS · ACTIVITY_GAP_GRACE_MS · ACTIVITY_RETENTION_DAYS
|
— | activity projector tuning. Defaults 2000, 10000, 180
|
WEBHOOKS_WORKER_ENABLED |
— |
false keeps outbound webhook delivery off on this instance. Default true
|
TERMINAL_IDLE_TIMEOUT_SEC · TERMINAL_MAX_DURATION_SEC
|
— | terminal attach limits. Defaults 900, 14400
|
TELEGRAM_POLL_TIMEOUT_S · TELEGRAM_API_BASE · TELEGRAM_INIT_DATA_MAX_AGE_S
|
— | Telegram tuning. Defaults 30, https://api.telegram.org, 3600
|
ADMIN_EMAIL · ADMIN_PASSWORD
|
— | non-interactive admin:create; set, run, then remove |
¹ Required unless SCHEDULER_ENABLED=false. The test-only variables (TEST_DATABASE_URL, TEST_REDIS_URL, BUN_BIN) are documented in the example file.
| Variable | Purpose |
|---|---|
API_URL |
target of the same-origin /api rewrite; http://api:8180 inside Compose |
NEXT_PUBLIC_LIVE_URL |
WebSocket URL of the API's live endpoint; wss://<host>/api/live behind the proxy |
| Variable | Purpose |
|---|---|
POSTGRES_PASSWORD |
must match DATABASE_URL
|
REDIS_PASSWORD |
must match REDIS_URL
|
The Telegram bot token and AI provider keys are not environment variables — an admin enters them under Admin › Integrations › Telegram and Admin › AI providers, and they are stored sealed.
- Pull the new version and rebuild both images.
- Replace the API container first and wait until it is healthy — it applies new migrations on start — then the web container. The reference
refresh.shdoes exactly this, and Caddy retries requests for up to 60 seconds meanwhile, so a deploy drops no requests. - Upgrade the runners after the API.
Back up together:
- the PostgreSQL volume — the source of truth;
- the attachments volume — card attachments are not in the database.
Redis holds only schedule timers, which the API rebuilds from PostgreSQL. Keep APP_ENCRYPTION_KEY and LLM_SECRETS_KEY out of database backups: either alone is useless, both together are the secrets.
Decisions: ADR-0007 · ADR-0015 · ADR-0016 · ADR-0031 · ADR-0059 · Spec: #368
Nautelle · Website · Discord · Engineering docs · MIT
Start
Work
Cost and health
Surfaces
Operate
Reference