For developers
Deployment
How Harbor runs in production on one server with Docker Compose and Caddy, how it is configured, and how backups leave the box.
One server
Production runs on one VPS with Docker Compose (compose.yaml at the repo root). One
Dockerfile at the root builds three images:
| Service | Image | Does |
|---|---|---|
web | Dockerfile, target web | The website and these docs, and the /app proxy |
app | Dockerfile, target app | The panel, under /app. Not published; the website reaches it on the docker network |
worker | Dockerfile, target tools | Background jobs (see Background jobs) |
migrate | Dockerfile, target tools | Applies database migrations, then exits. The panel and worker wait for it |
postgres | postgres:16-alpine | The database, bound to localhost only |
storage | SeaweedFS (S3-compatible) | Files, bound to localhost only |
mailpit | axllent/mailpit | Catches email in development |
caddy | caddy:2-alpine | TLS certificates, security headers, timeouts, compression, reverse proxy |
docker compose --profile prod up -d --build
Every port is published on 127.0.0.1 only. Docker opens published ports around the host
firewall, so a port without 127.0.0.1: would be on the internet.
A server shared with other sites
When another web server already owns ports 80 and 443 (the current VPS runs OpenLiteSpeed under
CyberPanel), use the override file compose.vps.yaml. Caddy then listens on 127.0.0.1:PROXY_PORT
with ops/caddy/Caddyfile.behind-proxy, and the other web server handles TLS and proxies the domain to it.
docker compose -f compose.yaml -f compose.vps.yaml --profile prod up -d --build
Put COMPOSE_FILE=compose.yaml:compose.vps.yaml and COMPOSE_PROFILES=prod in that server's .env
and plain docker compose up -d --build does the same.
Caddy takes the caller's address from X-Forwarded-For, trusting only a private-range peer and
reading the header right to left, so an address the client invented is ignored.
Caddy
ops/caddy/Caddyfile (Caddy on 80/443 itself) and ops/caddy/Caddyfile.behind-proxy both:
- set read and header timeouts, a 32 KB header limit and a 12 MB body limit,
- compress with zstd and gzip,
- fill in security headers only where the app did not set them (
?Header), - answer scanner probes (
/.env,/wp-admin…) with 404, - proxy to the website, overwriting
X-Real-IPso the app can trust it for rate limits.
The first also obtains and renews TLS certificates.
Configuration
Everything is in .env (see .env.example). The ones production cares most about:
| Setting | Notes |
|---|---|
AUTH_SECRET | 32 characters or more. Production refuses to start with less |
DATABASE_URL | The app's database role. It cannot UPDATE or DELETE audit rows |
NEXT_PUBLIC_SITE_URL, SITE_DOMAIN, APP_ORIGIN | Public address; the internal address of the panel |
SMTP_* | Real email. Without it, mail goes to the console |
S3_* | File storage |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | Live billing. Without them billing is simulated |
BACKUP_REMOTE, BACKUP_RCLONE_CONF | Where the nightly backup is copied |
Releasing
Check
pnpm typecheck,pnpm lint,pnpm testpass; CI is green.Build and restart
docker compose up -d --build. Themigrateservice applies new migrations before the panel and worker start. Migrations are additive where possible.Smoke test
Sign in, open the dashboard, open these docs.
Backups
ops/backup.sh runs nightly from cron. It produces:
| File | Contains |
|---|---|
harbor-YYYY-MM-DD.sql.gz | The database |
harbor-YYYY-MM-DD-files/ | The file store, mirrored |
harbor-YYYY-MM-DD.json | What was in them, for the Backup screen |
It checks the dump is whole before calling it good, and it is not finished until the copy is off
the box (rclone to S3, B2, R2, SFTP…). If it cannot get the copy off, that is a failure, not a
partial success. Restore with gunzip -c harbor-YYYY-MM-DD.sql.gz | psql … on a fresh database.
Each company can also export and restore its own data from Backup & restore.