VACVAC
Getting started

Architecture

How the control plane, worker, proxy, and database fit together — and the deliberate boundaries between them.

3 min readUpdated June 15, 2026Edition · v0.5.xBeta

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)    │
                     └──────────────────────────┘
ContainerRole
vac-apiThe 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-dbPostgres 16 — VAC's own state (apps, deployments, services, secrets, logs). Tuned small (shared_buffers=32MB, max_connections=50).
vac-proxyCaddy. 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:

  • defaultvac-api, vac-db, and vac-proxy reach each other here by container name.
  • vac-edgevac-proxy and every user app container join this network. Caddy routes to apps over it.
The control plane is sandboxed

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 :80 and :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:

  1. Clone / pull the repo at the target branch (or check out a pinned commit for a rollback).
  2. Resolve the build — detect or apply the configured build kind and produce a Compose file.
  3. Preflight — lint the resolved Compose for unsafe or unroutable configuration.
  4. Render .env — decrypt the app's secrets into a Compose env file.
  5. Builddocker compose build.
  6. Updocker compose up -d (with a memory-limit override if configured).
  7. Detect services — record containers, ports, and volumes.
  8. Sync routes — attach to vac-edge and push hostnames to Caddy.
  9. Health-check — wait for Caddy to report healthy upstreams.
  10. Running — the deployment settles, or lands in error / degraded.
Failure is a state, not a rollback

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.

Next

On this page