Architecture
How the control plane, worker, proxy, and database fit together — and the deliberate boundaries between them.
VAC runs as three containers on one host. Understanding how they relate — and the boundaries that are intentional — explains most of VAC's behavior, especially around routing and health.
The stack
┌─────────┐ git ┌──────────────────────────┐ routes ┌───────────┐
│ Repo │ ───────► │ vac-api (Go) │ ◄────────► │ vac-proxy │ ─► your apps
└─────────┘ │ HTTP server + deploy │ health │ (Caddy) │ (Compose
│ worker in one binary │ └───────────┘ stacks)
└──────────────────────────┘
▲
│ embedded SPA (go:embed)
┌──────────────────────────┐
│ ui (React 19 + Vite) │
└──────────────────────────┘| Container | Role |
|---|---|
vac-api | The control plane. A single Go binary that serves the HTTP API, embeds the React dashboard, and runs the deploy worker. It talks to the Docker socket to build and run your stacks. |
vac-db | Postgres 16 — VAC's own state (apps, deployments, services, secrets, logs). Tuned small (shared_buffers=32MB, max_connections=50). |
vac-proxy | Caddy. Terminates TLS, issues Let's Encrypt certs on demand, and routes each hostname to the right app container. It owns ports 80 and 443. |
The dashboard isn't a separate service: the React SPA is compiled into vac-api via go:embed
and served from the same binary.
Networks: why the API stays off the edge
Two Docker networks matter:
- default —
vac-api,vac-db, andvac-proxyreach each other here by container name. vac-edge—vac-proxyand every user app container join this network. Caddy routes to apps over it.
vac-api is deliberately not on vac-edge. User app containers can't reach the control plane, even though VAC manages them. This is a security boundary: your code can't poke at the API that has the Docker socket.
A direct consequence: vac-api can't probe your app containers itself, so deploy health-gating
goes through Caddy instead (more below).
Routing is by DNS alias, not host ports
Apps don't publish ports on the host. Instead, each HTTP service attaches to vac-edge with the
alias {app}--{service}, and Caddy routes the app's hostname to {app}--{service}:{internal_port}.
- The hostname for an app is
{app}.{domain}(the auto-subdomain), plus any custom domains you attach. - The internal port is auto-detected from the service's
expose:/ports:declaration, and can be overridden per service. - Caddy owns
:80and:443. Your apps never bind host ports for HTTP — that's why the build preflight blocks services that try to publish 80/443.
Caddy owns deploy health
Because vac-api can't reach app containers directly, Caddy is the source of truth for health.
A deploy is gated to running only when Caddy's /reverse_proxy/upstreams admin endpoint reports
the new upstreams healthy. Caddy runs active health checks against each service's health path
(default /). See Health checks.
The deploy pipeline
A deployment moves through ordered stages, each visible live in the dashboard:
- Clone / pull the repo at the target branch (or check out a pinned commit for a rollback).
- Resolve the build — detect or apply the configured build kind and produce a Compose file.
- Preflight — lint the resolved Compose for unsafe or unroutable configuration.
- Render
.env— decrypt the app's secrets into a Compose env file. - Build —
docker compose build. - Up —
docker compose up -d(with a memory-limit override if configured). - Detect services — record containers, ports, and volumes.
- Sync routes — attach to
vac-edgeand push hostnames to Caddy. - Health-check — wait for Caddy to report healthy upstreams.
- Running — the deployment settles, or lands in
error/degraded.
If any stage fails, the prior version keeps serving. VAC never tears down a running stack on a failed deploy; it records the failure as error or degraded state and leaves the last good version in place.
For the full stage-by-stage detail, see Deploy a repository.
Secrets at rest
Everything sensitive — app environment variables, per-app SSH deploy keys, operator TOTP secrets,
and notification webhook URLs — is encrypted with AES-256-GCM (crypto.Box), keyed by
VAC_MASTER_KEY. Nothing sensitive is stored in plaintext on disk. Lose the key and those values
can't be decrypted, which is why it's the one thing you must back up separately. See
Disaster recovery.
Cookies and TLS
Session cookies are marked Secure per request — only when the request actually arrived over
TLS (or through a trusted proxy reporting HTTPS), not by a static config flag. That keeps the
http://<host>:9393 fallback usable while still hardening real HTTPS traffic.