Docker with Caddy

Self-host the full Carbon stack on a single Linux VPS with a Docker Swarm and an automatic-HTTPS Caddy reverse proxy.

Run Carbon on one Linux VPS with a single-node Docker Swarm. The stack includes ERP, MES, Supabase, Redis, and Inngest behind a Caddy TLS reverse proxy. Only ports 80 and 443 are public; other services use a private overlay network. Sensitive values use Docker Swarm secrets.

Deployment files are in contrib/deploying/simple-docker-caddy. Use deploy.sh to manage the deployment lifecycle.

TIP

Don't want to run any of this?

Carbon Cloud manages infrastructure, backups, upgrades, and availability. Self-host when Carbon must run on infrastructure you control.

What runs

ServiceRole
caddyReverse proxy + automatic HTTPS. The only service that publishes ports (80/443).
erp, mesThe Carbon apps, built from the repo. Reached at erp.<domain> and mes.<domain>.
postgresSupabase Postgres, tuned on first boot (pg_stat_statements, connection limits).
gotrue, postgrest, realtime, storage, meta, kongThe Supabase data plane, fronted by Kong at api.<domain>.
studioSupabase Studio (DB admin), internal-only by default.
edge-runtimeSupabase Edge Functions.
redisCache, rate limiting, session state.
inngestBackground jobs for both apps.

Prerequisites

RequirementDetails
VPSUbuntu 22.04+, ≥ 4 GB RAM to run the stack. Building images on the host needs ~8 GB. Add swap or build elsewhere.
DockerDocker Engine with the Compose plugin.
OpenSSLdeploy.sh uses openssl to generate secrets and the Supabase JWT keys (usually already present).
DNSA/AAAA records for your ERP, MES, and Supabase hosts pointing at the VPS (needed for Let's Encrypt).
RepoThe Carbon repo checked out on the VPS (default /opt/carbon).

Install

Configure a firewall for SSH and ports 80 and 443, fail2ban, swap, and automatic security updates.

bash
cd /opt/carbon/contrib/deploying/simple-docker-caddy
sudo ./scripts/harden.sh

Initialize

Start the Swarm, generate Docker secrets, and create .env from the template.

bash
./deploy.sh init

Configure

Edit .env: set CARBON_REPO, your *_HOST / *_URL hostnames, ACME_EMAIL, and SMTP settings.

bash
$EDITOR .env

Set the operator secrets

init creates placeholder values for deployment. Replace them with production secrets. ERP does not start with an empty Resend key.

bash
./deploy.sh secret resend_api_key re_xxxxxxxx
./deploy.sh secret smtp_password 'your-smtp-password'

Deploy

Build the images, deploy the stack, wait for the database, and apply migrations.

bash
./deploy.sh up

Verify

bash
./deploy.sh status
curl -fsS https://$ERP_HOST/health   # -> 200
curl -fsS https://$MES_HOST/health   # -> 200
NOTE

The first boot applies all migrations. Applications can restart until the schema is ready. Monitor progress with ./deploy.sh logs erp.

How secrets work

Swarm mounts secrets under /run/secrets/, while the images read environment variables. The entrypoint replaces each __SECRET_NAME__ placeholder with the matching secret file's contents before starting the service.

yaml
PGRST_DB_URI: postgres://authenticator:__POSTGRES_PASSWORD__@postgres:5432/postgres
GOTRUE_JWT_SECRET: __JWT_SECRET__

deploy.sh init generates postgres_password, session_secret, the Inngest keys, and the Supabase key trio (jwt_secret, anon_key, service_role_key). You supply resend_api_key and smtp_password.

HEADS UP

inngest_signing_key must be plain hex. The self-hosted Inngest server rejects the signkey- prefix. init generates it correctly.

Operating

TaskCommand
Status./deploy.sh status
Follow logs./deploy.sh logs <service> (e.g. erp, caddy)
Update to a new releasegit pull && ./deploy.sh build && ./deploy.sh deploy && ./deploy.sh migrate
Back up./scripts/backup.sh (Postgres dump + storage archive)
Stop./deploy.sh down (add --volumes to also wipe data)

Database admin. Studio is internal-only by default. Reach the database over SSH with:

bash
docker exec -it $(docker ps -qf name=carbon_postgres.1) psql -U postgres

The .1 avoids also matching carbon_postgrest. Or expose Studio behind HTTP basic-auth by setting STUDIO_HOST and uncommenting the studio block in the Caddyfile.

Database events. Postgres delivers its events (the event queue behind webhooks, workflows and search, part embeddings, job notifications) straight to Inngest, at a URL ./deploy.sh migrate stores in the database once the erp service is running. If migrate warns that it could not wire them, run it again once the stack is up. After rotating inngest_event_key, run ./deploy.sh migrate again.

Rotate a secret. Swarm secrets are immutable while in use, so bring the stack down first:

bash
./deploy.sh down
./deploy.sh secret <name> 'new-value'
./deploy.sh up
HEADS UP

Schedule backup.sh, copy its output offsite, and enable provider volume snapshots. Backups stored only on the VPS do not protect against host loss.

Production checklist

  • Host hardened (harden.sh): firewall limited to SSH + 80/443, fail2ban, swap.
  • HTTPS issuing cleanly — ./deploy.sh logs caddy.
  • Real resend_api_key and working SMTP set (Auth needs SMTP for invites and magic links).
  • Postgres tuning sized to the box (the PG_* values in .env).
  • Backups scheduled and shipped offsite.
  • Studio not publicly exposed (or behind basic-auth).

See Environment variables for the full configuration surface, or AWS with SST for an AWS setup.