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.
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
| Service | Role |
|---|---|
caddy | Reverse proxy + automatic HTTPS. The only service that publishes ports (80/443). |
erp, mes | The Carbon apps, built from the repo. Reached at erp.<domain> and mes.<domain>. |
postgres | Supabase Postgres, tuned on first boot (pg_stat_statements, connection limits). |
gotrue, postgrest, realtime, storage, meta, kong | The Supabase data plane, fronted by Kong at api.<domain>. |
studio | Supabase Studio (DB admin), internal-only by default. |
edge-runtime | Supabase Edge Functions. |
redis | Cache, rate limiting, session state. |
inngest | Background jobs for both apps. |
Prerequisites
| Requirement | Details |
|---|---|
| VPS | Ubuntu 22.04+, ≥ 4 GB RAM to run the stack. Building images on the host needs ~8 GB. Add swap or build elsewhere. |
| Docker | Docker Engine with the Compose plugin. |
| OpenSSL | deploy.sh uses openssl to generate secrets and the Supabase JWT keys (usually already present). |
| DNS | A/AAAA records for your ERP, MES, and Supabase hosts pointing at the VPS (needed for Let's Encrypt). |
| Repo | The Carbon repo checked out on the VPS (default /opt/carbon). |
Install
Harden the host (recommended)
Configure a firewall for SSH and ports 80 and 443, fail2ban, swap, and automatic security updates.
cd /opt/carbon/contrib/deploying/simple-docker-caddy
sudo ./scripts/harden.shInitialize
Start the Swarm, generate Docker secrets, and create .env from the template.
./deploy.sh initConfigure
Edit .env: set CARBON_REPO, your *_HOST / *_URL hostnames, ACME_EMAIL, and SMTP settings.
$EDITOR .envSet the operator secrets
init creates placeholder values for deployment. Replace them with production secrets. ERP does not start with an empty Resend key.
./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.
./deploy.sh upVerify
./deploy.sh status
curl -fsS https://$ERP_HOST/health # -> 200
curl -fsS https://$MES_HOST/health # -> 200The 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.
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.
inngest_signing_key must be plain hex. The self-hosted Inngest server rejects the signkey- prefix. init generates it correctly.
Operating
| Task | Command |
|---|---|
| Status | ./deploy.sh status |
| Follow logs | ./deploy.sh logs <service> (e.g. erp, caddy) |
| Update to a new release | git 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:
docker exec -it $(docker ps -qf name=carbon_postgres.1) psql -U postgresThe .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:
./deploy.sh down
./deploy.sh secret <name> 'new-value'
./deploy.sh upSchedule 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_keyand 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.