Install
Control Tower is one process with one data directory: a SQLite database (controltower.db) and master.key, which encrypts the provider credentials you store. Run it however you run containers.
Docker
docker run -d --name controltower \
-p 4000:4000 \
-v controltower-data:/data \
ghcr.io/joshmaster2165/controltower:0.2.1- Tags:
latestis the newest release,0.2.1pins one,mainfollows the main branch. Every image is signed: see Verifying the image. - Architectures:
linux/amd64andlinux/arm64. - Data: keep
/dataon a named volume (or a platform disk). Without one, Docker gives each new container an empty anonymous volume, so an upgrade starts from scratch. On platforms that ignore the image'sVOLUME, the server warns at startup that/datais not on a volume. - Port: 4000, or
PORT/CT_PORT/--port. - First run: the log prints a setup code and an Open link that carries it (
docker logs controltower). The setup page asks for the code, so only someone who can read the log creates the first admin. SetCT_ADMIN_KEYto skip the setup page, orCT_SETUP_TOKENto choose the code — see Configuration. - The container runs as the non-root user
node. The app's own files belong to root and are read-only to it: only/datais writable. If a platform mounts/dataowned by root, the entrypoint fixes the ownership before dropping privileges.
Flags go after the image name:
docker run -p 4000:4000 -v controltower-data:/data \
-v $(pwd)/config.yaml:/app/config.yaml \
-e CT_ADMIN_KEY=sk-… -e OPENAI_API_KEY=sk-… \
ghcr.io/joshmaster2165/controltower --config /app/config.yaml --detailed_debugSee Config file for what the file can contain, and Configuration for every flag and environment variable.
Docker Compose
docker compose -f deploy/docker-compose.yml up -ddeploy/docker-compose.yml runs the published image with a named volume and a restart policy. Uncomment CT_DEMO, CT_PUBLIC_URL or CT_MASTER_KEY as needed.
Render
render.yaml deploys the published image on a Starter instance with a 1 GB disk mounted at /data (Render disks need a paid instance). Links in alerts use the onrender.com URL automatically.
Fly.io
With flyctl:
fly launch --config deploy/fly.toml --copy-config --no-deploy # pick an app name and region
fly volumes create controltower_data --size 1
fly deploy --config deploy/fly.tomldeploy/fly.toml mounts the volume at /data and health-checks /healthz.
Railway
Railway runs the published image directly; the only extra step is a volume, so the database and master key survive redeploys.
- In Railway, create a project and choose Docker Image as the source:
ghcr.io/joshmaster2165/controltower:latest(or pin:0.1.5). - Right-click the service → Attach Volume, mount path
/data. Railway mounts volumes as root; the image fixes the ownership at startup and still runs as a non-root user. - Settings → Networking → Generate Domain. Railway sets
PORTand the image listens on it. - Optional: Settings → Deploy → Healthcheck Path
/healthz. - Open the domain and create the admin account, with the setup code from the deploy logs — or set
CT_ADMIN_KEYunder Variables first to skip that step and sign in asadmin.
Links in alerts and approval messages use the Railway domain automatically (RAILWAY_PUBLIC_DOMAIN); set CT_PUBLIC_URL if you add a custom domain. To run from a config file, build a small image FROM ghcr.io/joshmaster2165/controltower that copies in your config.yaml, set CT_CONFIG to its path, and put the provider keys in Variables.
Kubernetes
A Helm chart installs Control Tower as one pod on SQLite, or several on Postgres and Redis:
helm install controltower oci://ghcr.io/joshmaster2165/charts/controltower --version 0.2.1See Kubernetes.
Any container platform
Use ghcr.io/joshmaster2165/controltower, mount a volume at /data, and send traffic to port 4000, or to the PORT the platform sets. Set CT_PUBLIC_URL to the public address so links in alerts and approval messages point at your console (detected automatically on Render, Fly.io and Railway).
Health checks:
| Path | Use |
|---|---|
/healthz, /health/liveliness, /health/liveness | Liveness: the process is up |
/readyz, /health/readiness | Readiness: accepting traffic (503 while shutting down). /readyz answers {ok, shutting_down}; with the admin key it adds the event backlog and database details |
/health | Checks every connected provider; needs the admin key or an agent key |
On SIGTERM the server stops accepting requests, turns every request waiting for approval into a ticket the agent can retry, lets streams finish (up to 15 s), then flushes and exits.
Verifying the image
From 0.1.8, CI signs every image it publishes with Sigstore cosign. The signing is keyless: the signature is tied to this repository's CI workflow, with no private key to leak. Check that an image came from that workflow, for a release tag:
cosign verify ghcr.io/joshmaster2165/controltower:0.2.1 \
--certificate-identity-regexp '^https://github.com/joshmaster2165/controltower/.github/workflows/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe identity must be one of this repository's workflows. Only its maintainers can change or run them. Use cosign 3 or later.
GitHub's own build attestation says which commit and workflow run built it:
gh attestation verify oci://ghcr.io/joshmaster2165/controltower:0.2.1 --repo joshmaster2165/controltowerEach image also carries an SBOM (every package inside it) and full build provenance, as attestations next to the image:
docker buildx imagetools inspect ghcr.io/joshmaster2165/controltower:0.2.1 --format '{{ json .SBOM }}'The Helm chart is signed the same way: cosign verify ghcr.io/joshmaster2165/charts/controltower:0.2.1 with the same two flags.
Behind a corporate proxy
If the server reaches the internet only through a proxy, set the standard variables; Control Tower's own calls — to model providers, MCP servers, HTTP APIs, alert channels and token endpoints — then go through it (HTTPS by tunnelling):
docker run -p 4000:4000 -v controltower-data:/data \
-e HTTPS_PROXY=http://proxy.corp.example:3128 \
-e NO_PROXY=.corp.example,10.0.0.0/8 \
ghcr.io/joshmaster2165/controltowerHTTP_PROXY covers http:// targets (and https:// ones when HTTPS_PROXY isn't set). NO_PROXY lists hosts that are reached directly — typically internal model servers and MCP servers. Lowercase names work too. The server's own address is never proxied, and the startup log says which proxy is in use (without credentials).
From source
Needs Node 24 and pnpm.
git clone https://github.com/joshmaster2165/controltower && cd controltower
pnpm install
pnpm build # console, server bundle
pnpm start # http://localhost:4000
pnpm start --config config.yaml # same flags as the containerFor development: CT_DEMO=1 pnpm dev runs the server with reload, and pnpm dev:ui serves the console with hot reload on http://localhost:5173.
Upgrading
Pull the new image and restart with the same volume. Database migrations run at startup, forward only. Take a copy of /data first if you want to be able to roll back.
docker pull ghcr.io/joshmaster2165/controltower:latest
docker rm -f controltower && docker run -d --name controltower -p 4000:4000 -v controltower-data:/data ghcr.io/joshmaster2165/controltower:latestBackups
Back up the whole data directory: controltower.db (and its -wal / -shm files while running) and master.key. Without master.key, the stored provider and tool-server credentials cannot be decrypted. Alternatively, supply the key yourself with CT_MASTER_KEY (base64, 32 bytes) and keep it in your secret store.