Template for self-hosting Sovereign on a VPS.
Fork or use as a GitHub template. Run ./configure.sh to set your domains, bootstrap
a VPS, push a version tag — CI handles the rest.
Stack: Caddy (auto-TLS) · Docker Compose · age-encrypted secrets · GitHub Actions CI/CD. No Terraform, no Ansible, no managed cloud required. Any Ubuntu 24.04 VPS works.
1. Fork / use as template → your-org/sovereign-infra
2. Clone locally and run ./configure.sh
3. Commit the domain config and push to main
4. Provision a VPS and run bootstrap/setup.sh
5. Encrypt your .env and push to main (CI installs it)
6. Push a version tag → CI deploys Sovereign
Full walkthrough below.
| Repo | Role | Trigger | What it does |
|---|---|---|---|
sovereignfs/sovereign |
Provider | push v* tag |
builds Docker images, pushes to GHCR |
<you>/sovereign-infra |
Operator | push any tag | decrypts + installs app envs, reloads Caddy |
<you>/sovereign-infra |
Operator | push matching v* tag |
verifies GHCR images exist, deploys to VPS |
Sovereign publishes images when tagged. You deploy those images by pushing the same tag here.
They are independent — see docs/sovereign-deploy-workflow.md for the full model.
.github/workflows/
sync.yml # any tag → sync (secrets + Caddy); v* tag → also deploy (needs: sync)
bootstrap/
setup.sh # one-time VPS provisioning (run as root)
caddy/
Caddyfile # imports conf.d/*.caddy — do not add vhosts here directly
conf.d/
sovereign.caddy # your Sovereign vhosts (configured by ./configure.sh)
_template.caddy.example # copy this when adding a new app
docker-compose.yml
apps/
sovereign/
.env.example # deployment skeleton — fill in and encrypt
docker-compose.override.yml.example # workaround for upstream compose gaps
# No docker-compose.yml — fetched from the sovereign release at deploy time
_template/ # copy this when adding a new non-sovereign app
docker-compose.yml
.env.example
scripts/
configure.sh # one-time domain setup (run locally)
generate-env.js # env.yml + latest upstream .env.example → .env, .env.enc
encrypt-env.sh # encrypt apps/<name>/.env → .env.enc (called by generate-env.js)
decrypt-env.sh # decrypt .env.enc → /opt/apps/<name>/.env
backup-sovereign.sh # daily backup (run from cron on VPS)
restore-sovereign.sh # restore from a dated backup
install-backup-cron.sh # one-time backup cron + repo setup on VPS
logs.sh # convenience docker logs wrapper
env.example.yml # template for env.yml — copy it, fill in real values, never commit the copy
docs/
ports.md # port registry — prevents collisions
sovereign-deploy-workflow.md # two-repo model + deploy steps
/opt/infra/ ← git clone of this repo
/opt/apps/
sovereign/
docker-compose.prod.yml ← fetched from sovereignfs/sovereign on each deploy
docker-compose.postgres.yml ← fetched from sovereignfs/sovereign on each deploy
docker-compose.override.yml ← copied from infra repo if present (optional)
.env ← NOT in git; decrypted by CI from .env.enc
.deploy-version ← currently deployed tag
myapp/ ← example future app (infra-managed compose)
docker-compose.yml ← symlink → /opt/infra/apps/myapp/docker-compose.yml
.env
/opt/backups-repo/ ← clone of your backup repo
- Prerequisites
- Domain configuration
- Secrets setup (age)
- DNS setup
- VPS bootstrap
- Caddy — reverse proxy
- Sovereign deployment
- CI/CD pipeline
- Adding a new app
- Rollback
- Backups
- Secrets management
- Reference
- Troubleshooting
Local machine:
gitage—brew install age(macOS) /apt install age(Ubuntu)- Node.js ≥18 — for
scripts/generate-env.js(npm installonce, see "Secrets Management") - SSH key pair for CI (generated below)
VPS:
- Ubuntu 24.04 LTS (any provider: Hetzner, DigitalOcean, Linode, etc.)
- At least 1 GB RAM, 1 vCPU — Sovereign runs comfortably on a $5–6/month instance
- A domain name with DNS you control
GitHub:
- This repo forked or used as template (must be private — it will contain
.env.encfiles) - A separate private repo for encrypted backups (created in step 11)
After forking, run ./configure.sh once to stamp your domains into the config files:
./configure.shIt prompts for three values:
| Prompt | Example | Used for |
|---|---|---|
| Runtime domain | example.com |
Main app URL, WebAuthn origin |
| Auth domain | auth.example.com |
Auth server URL, WebAuthn origin |
| Root domain | example.com |
WebAuthn RP_ID, cookie domain, email sender |
The script edits caddy/conf.d/sovereign.caddy, apps/sovereign/.env.example, and
docs/ports.md in place. Review the diff, then commit:
git diff
git add caddy/conf.d/sovereign.caddy apps/sovereign/.env.example docs/ports.md
git commit -m "config: set domain to example.com"
git push origin mainSecrets are encrypted with age before being committed. The age private key never leaves your local machine (and GitHub Actions secrets).
# Generate your age keypair
age-keygen -o ~/.age/key.txt
# Printed: Public key: age1...
# Export the public key for use in encrypt-env.sh
echo 'export AGE_PUBLIC_KEY=age1...' >> ~/.zshrc
source ~/.zshrcSave ~/.age/key.txt to your password manager immediately. It contains both keys. This is
the only copy.
ssh-keygen -t ed25519 -C "sovereign-ci-deploy" -f ~/.ssh/sovereign_ci_deploy
# ~/.ssh/sovereign_ci_deploy ← private key → GitHub secret VPS_SSH_KEY
# ~/.ssh/sovereign_ci_deploy.pub ← public key → pasted during bootstrapAdd two A records pointing to your VPS IP (both use the same IP; Caddy routes by hostname):
| Type | Name | Value |
|---|---|---|
| A | example.com |
<VPS IP> |
| A | auth.example.com |
<VPS IP> |
Verify propagation before continuing (the VPS must be reachable at these names for Caddy to provision Let's Encrypt certificates):
dig +short example.com
dig +short auth.example.comRun once on a fresh Ubuntu 24.04 VPS as root.
Create a fine-grained GitHub PAT with Contents → Read access to this repo, then:
ssh root@<VPS IP>
apt-get install -y git
git clone https://x-access-token:<PAT>@github.com/<your-org>/sovereign-infra.git /opt/infraThe PAT is embedded in the remote URL so future git pull calls work without
re-entering credentials. To rotate it later:
sudo -u deploy git -C /opt/infra remote set-url origin \
https://x-access-token:<NEW_PAT>@github.com/<your-org>/sovereign-infra.gitbash /opt/infra/bootstrap/setup.shThe script pauses to ask for the CI public key — paste the contents of
~/.ssh/sovereign_ci_deploy.pub (from your local machine), then signal end-of-input:
# Paste the key, press Enter to go to a new line, then Ctrl+D.
# If Ctrl+D seems to do nothing, press Enter first — the terminal needs a
# newline after the pasted content before it will accept the EOF signal.
The script handles: Docker (with log rotation), jq, deploy user creation, SSH hardening (password auth disabled), firewall (22/80/443 only), logrotate, and directory setup.
Important: the script prints the deploy user's password at the end — save it in your password manager immediately. This password is only for emergency VPS web-console access. Normal SSH requires the key.
If you missed the password (e.g. it scrolled past), reset it now while still logged in as root:
DEPLOY_PASS=$(openssl rand -base64 24 | tr -d '/+=')
echo "deploy:$DEPLOY_PASS" | chpasswd
echo "New deploy password: $DEPLOY_PASS"Caddy runs as a standalone Docker container with network_mode: host, so it can reach
any app on localhost without a shared Docker network.
# caddy/Caddyfile — imports per-app files; do not add vhosts here
(common_headers) {
header {
-Server
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
import /etc/caddy/conf.d/*.caddy# caddy/conf.d/sovereign.caddy — configured by ./configure.sh
example.com {
import common_headers
reverse_proxy 127.0.0.1:4000 {
flush_interval -1 # required for SSE (live notifications)
}
}
auth.example.com {
import common_headers
reverse_proxy 127.0.0.1:4001
}A bad block in one .caddy file never affects others. The sync workflow validates config
before reloading, so a broken push can't take down Caddy.
cd /opt/infra/caddy
docker compose up -d
docker logs caddy -f
# Watch for: "certificate obtained successfully" for each domainConfig changes pushed to main are applied automatically by CI. To reload manually:
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile# Locally, in the infra repo:
cp env.example.yml env.yml # git ignored — never commit this copy
nano env.yml # fill in all required values
node scripts/generate-env.js sovereign
git add apps/sovereign/.env.example apps/sovereign/.env.enc
git commit -m "secrets: sovereign initial"
git push origin main
# CI decrypts and installs /opt/apps/sovereign/.env on the VPS automaticallygenerate-env.js fetches the latest upstream apps/sovereign/.env.example, renders
apps/sovereign/.env from env.yml, and encrypts it to apps/sovereign/.env.enc — see
"Secrets Management" for the full workflow, options, and what to
do when validation fails.
Key points:
COMPOSE_FILE=docker-compose.prod.yml:docker-compose.postgres.ymlis set in.envso plaindocker composecommands automatically use both files.AUTH_WEBAUTHN_RP_IDmust be the bare registrable domain (e.g.example.com). Changing it after passkeys are registered invalidates all existing passkeys.DATABASE_URLandAUTH_DATABASE_URLare constructed by the Postgres overlay — do not set them directly.- Postgres is not exposed on any host port — only reachable inside the Docker network.
Backups use
docker exec, not TCP.
The upstream docker-compose.prod.yml is missing several env vars (passkeys, VAPID,
log level, notification transport). Activate the included override to fix this:
cp apps/sovereign/docker-compose.override.yml.example \
apps/sovereign/docker-compose.override.yml
git add apps/sovereign/docker-compose.override.yml
git commit -m "config: enable compose override"
git push origin mainCI automatically includes the override file in the compose stack when it is present.
See apps/sovereign/docker-compose.override.yml.example for details.
git tag v0.9.10 && git push origin v0.9.10CI verifies the GHCR images exist, fetches compose files from sovereignfs/sovereign@v0.9.10,
pulls images, and starts the stack. Watch progress in your repo's Actions tab.
Verify:
curl -s https://example.com/api/health
curl -s https://auth.example.com/api/healthOpen https://example.com. The first user to register becomes admin.
After registering, lock registration:
# Locally:
nano env.yml # set apps.sovereign.AUTH_INVITE_ONLY: 'true'
node scripts/generate-env.js sovereign
git add apps/sovereign/.env.enc && git commit -m "config: enable invite-only"
git push origin main
# Apply immediately:
ssh deploy@<VPS IP> "cd /opt/apps/sovereign && docker compose up -d"A single workflow file (.github/workflows/sync.yml) handles everything with a sync
job plus one of two deploy jobs, chosen automatically by whether sovereign.plugins.json
exists at the repo root:
Push any tag e.g. git tag caddy && git push origin caddy
│
▼
Job 1 — sync (always runs)
├── Decrypt all apps/*/.env.enc (dynamic — no hardcoded app list)
├── Install each app's .env on VPS
├── git pull /opt/infra
└── Validate + reload Caddy (zero-downtime)
Push v* tag, sovereign.plugins.json ABSENT → Job "deploy" (published image)
Verify GHCR images exist for this tag
SSH into VPS:
curl docker-compose.prod.yml / .postgres.yml from sovereignfs/sovereign@vX.Y.Z
Copy docker-compose.override.yml if present in infra repo (compose-gap workaround)
docker compose pull && docker compose up -d
docker image prune -f · echo vX.Y.Z > .deploy-version
Push v* tag, sovereign.plugins.json PRESENT → Job "deploy-custom" (private plugins)
Decrypt apps/_plugin-tokens/.env.enc if present (age, same key as everything else)
Clone sovereignfs/sovereign@vX.Y.Z, overlay this repo's sovereign.plugins.json
docker buildx build --secret id=plugin_tokens,src=<decrypted-tokens-file> --load
docker save | gzip → scp to VPS → docker load (no registry involved)
SSH into VPS: copy docker-compose.override.yml if present, write
docker-compose.custom-image.yml pointing runtime at the loaded image,
pull only sovereign-auth (still published), docker compose up -d
If the GHCR images don't exist for that tag, both deploy jobs fail before touching the VPS — the running version stays live either way. See "Installing private plugins" below for the custom-image path in full.
Add these to your infra repo → Settings → Secrets and variables → Actions → Repository secrets → New repository secret:
These must be repository secrets (the "Variables" tab is for non-sensitive values and will not work for these).
| Secret | Value |
|---|---|
VPS_HOST |
VPS IP address |
VPS_USER |
deploy |
VPS_SSH_KEY |
Contents of ~/.ssh/sovereign_ci_deploy (private key) |
AGE_PRIVATE_KEY |
Contents of ~/.age/key.txt (age private key) |
All four must exist before pushing any tag, otherwise the decrypt step fails immediately. No extra GitHub secret is needed for private plugins — see below.
By default this repo deploys sovereign exactly as sovereignfs/sovereign published it — no
build step, just a docker compose pull. To add a plugin from a private GitHub repo, commit a
sovereign.plugins.json at this repo's root:
{
"plugins": [
{
"id": "com.acme.crm",
"repository": "https://github.com/acme/sovereign-crm",
"tokenEnv": "ACME_CRM_PLUGIN_TOKEN"
}
]
}tokenEnvcan be any name you choose, and each plugin can use a different one — the platform Dockerfile mounts a build secret file of arbitraryVAR=valuelines, not a single named variable, so there's no cap and nothing to coordinate across plugins.- Set the actual token values in
apps/_plugin-tokens/.env— copy fromapps/_plugin-tokens/.env.example, add one line pertokenEnvname, then encrypt it exactly like any other app's secrets:This rides on thecp apps/_plugin-tokens/.env.example apps/_plugin-tokens/.env nano apps/_plugin-tokens/.env # ACME_CRM_PLUGIN_TOKEN=ghp_xxx ./scripts/encrypt-env.sh _plugin-tokens git add apps/_plugin-tokens/.env.enc git commit -m "secrets: add plugin token for com.acme.crm" git push origin main
AGE_PRIVATE_KEYsecret you already have — no new GitHub Actions secret to create. Unlike every otherapps/*/.env.enc, this one is never installed on the VPS; thesyncjob explicitly skips it, and it's decrypted only insidedeploy-custom, used to build the image, then discarded. - A fine-grained GitHub PAT scoped to just that repository with Contents: Read-only is the
least privilege that works. One PAT can also cover several repos under the same owner, in which
case those plugins can share a
tokenEnv. - Public plugins can go in the same file with no
tokenEnvfield at all.
Once sovereign.plugins.json exists, every subsequent v* tag automatically takes the
custom-build path — deploy-custom instead of deploy. Nothing else changes: same tag
command (git tag v0.9.10 && git push origin v0.9.10), same .deploy-version tracking
(suffixed -custom so cat /opt/apps/sovereign/.deploy-version tells you which path last ran).
The existing docker-compose.override.yml compose-gap workaround (see Compose
override above) still applies on the custom-build path — it's
copied from the infra repo and chained into COMPOSE_FILE alongside the new
docker-compose.custom-image.yml, which is reserved for the runtime-image redirect and always
distinct from the override file.
To go back to the published-image path, delete sovereign.plugins.json and push a new tag — the
next deploy fetches sovereign's own compose files fresh and removes any leftover
docker-compose.custom-image.yml from a previous custom deploy.
Only sovereign-runtime is ever custom-built — sovereign-auth doesn't compose plugins at
all, so it always pulls sovereignfs's published image regardless of which path is active.
Caveats:
- The build runs in the GitHub Actions runner, not the VPS, and never touches a registry — the
image is
docker saved, shipped to the VPS over SSH, anddocker loaded there. Expect thedeploy-customjob to take noticeably longer than a plain image pull (a full monorepo build plus a several-hundred-MB transfer), and expect it to use more Actions minutes. - This whole mechanism exists because of a real gap in
sovereignfs/sovereign's own Dockerfile: a plaindocker compose up --builddoes not pass a build secret through on its own — private-repo plugin cloning needs an explicitdocker buildx build --secret id=plugin_tokens,src=<tokens-file>invocation, which is exactly whatdeploy-customruns on your behalf.
# Confirm the sovereign release has published GHCR images first:
docker manifest inspect ghcr.io/sovereignfs/sovereign-runtime:v0.9.10
# Tag the infra repo at the same version:
git tag v0.9.10 && git push origin v0.9.10cat /opt/apps/sovereign/.deploy-versionSay you want to host myapp.example.com on port 5000.
1. Pick a port — open docs/ports.md, pick the next free port, add a row.
2. Add the Caddy vhost
cp caddy/conf.d/_template.caddy.example caddy/conf.d/myapp.caddy
# Edit: set domain and port. Add flush_interval -1 for SSE apps.3. Add the app stack
cp -r apps/_template apps/myapp
# Edit docker-compose.yml: image, port, env vars
# Edit .env.example: list every variable the app needs4. Set up secrets
cp apps/myapp/.env.example apps/myapp/.env
nano apps/myapp/.env
./scripts/encrypt-env.sh myapp5. Push to main
git add caddy/conf.d/myapp.caddy apps/myapp/ docs/ports.md
git commit -m "feat: add myapp"
git push origin main
# CI reloads Caddy — TLS provisioned automatically6. First start (VPS, one time)
ssh deploy@<VPS IP>
mkdir -p /opt/apps/myapp
ln -sf /opt/infra/apps/myapp/docker-compose.yml /opt/apps/myapp/docker-compose.yml
cd /opt/apps/myapp
APP_VERSION=v1.0.0 docker compose pull
APP_VERSION=v1.0.0 docker compose up -d7. Add DNS record
A myapp.example.com <VPS IP>
Sovereign's native mobile push notifications depend by default on a small,
end-to-end-encrypted relay operated by sovereignfs
(relay.sovereign.openfs.io — see sovereignfs/sovereign's
docs/rfcs/0085-sovereign-relay.md). Most operators need to do nothing —
it works out of the box.
If you want zero dependency on it, the escape hatch requires three things together, not any one alone:
- Your own signed
sovereign-mobileapp build, under your own Apple Developer account / Firebase project (required — APNs/FCM tokens are permanently bound to whichever app identity generated them). - Your own APNs
.p8key and FCM service-account credential, obtainable because you now own that app identity. - Your own deployed relay, configured with those credentials.
This repo already has the files for step 3: apps/relay/ and
caddy/conf.d/_relay.caddy.example are pre-written, following the same
"Adding a New App" pattern as above. The fastest path is answering yes to
the relay prompt in ./configure.sh — it asks for a relay domain and
creates caddy/conf.d/relay.caddy for you. From there, follow the same
steps 4–7 above (relay in place of myapp) to set secrets and deploy —
configure.sh's own output repeats the exact commands once you opt in.
If you skip this — the default, recommended choice — no relay-related files are created and nothing changes about how Sovereign runs.
# Push a previous tag to trigger CI deploy:
git tag v0.9.9 && git push origin v0.9.9
# Or directly on VPS (no CI needed):
ssh deploy@<VPS IP>
cd /opt/apps/sovereign
SOVEREIGN_VERSION=v0.9.9 COMPOSE_FILE=docker-compose.prod.yml:docker-compose.postgres.yml \
docker compose pull
SOVEREIGN_VERSION=v0.9.9 COMPOSE_FILE=docker-compose.prod.yml:docker-compose.postgres.yml \
docker compose up -dData lives in sovereign_pgdata (Postgres) and sovereign_data (uploads/plugins) named
Docker volumes. Neither is touched by an image swap.
Revert the commit in your infra repo and push to main. CI applies it automatically.
| Component | Contents | Method |
|---|---|---|
| Postgres database | Platform + auth data | pg_dump → gzip → age encrypt |
| User avatars | Profile images | tar → age encrypt |
| Plugin manifest | sovereign.plugins.json |
copy → age encrypt |
| Isolated plugin DBs | plugins/*.db SQLite files |
tar → age encrypt |
All four are age-encrypted before leaving the VPS and pushed to your private backup repo as a dated commit. Runs daily at 03:00 UTC, retained 30 days. Components with no data yet are skipped cleanly.
Note: restoring Postgres alone is not sufficient — isolated plugin databases live in
the sovereign_data volume, not in Postgres. Restore all four components for a complete
recovery.
1. Create the backup repo on GitHub
Create a private repo (e.g. <your-org>/sovereign-backups). Leave it empty — the
first successful backup initializes the main branch.
2. Create a GitHub PAT for backup writes
GitHub → Settings → Developer settings → Fine-grained tokens:
- Resource owner:
<your-org> - Repository:
sovereign-backupsonly - Permissions: Contents → Read and write
3. Add backup config to your .env
nano apps/sovereign/.env
# Set:
# BACKUP_GITHUB_TOKEN=<token>
# BACKUP_GITHUB_REPO=<your-org>/sovereign-backups
# AGE_PUBLIC_KEY=age1... (from step 3 above)
./scripts/encrypt-env.sh sovereign
git add apps/sovereign/.env.enc && git commit -m "secrets: add backup credentials"
git push origin main4. Install the backup cron on the VPS
Backups are not enabled automatically by bootstrap/setup.sh. Bootstrap only
creates /opt/backups-repo with the right ownership. Install the cron after
apps/sovereign/.env.enc has been pushed and CI has decrypted it to
/opt/apps/sovereign/.env, because the installer needs BACKUP_GITHUB_TOKEN,
BACKUP_GITHUB_REPO, and AGE_PUBLIC_KEY.
ssh deploy@<VPS IP>
cd /opt/infra
./scripts/install-backup-cron.shbootstrap/setup.sh creates /opt/backups-repo and gives it to the deploy user.
On older servers provisioned before that directory existed, create or fix it once as
root:
sudo install -d -o deploy -g deploy /opt/backups-repo
# or, if it already exists:
sudo chown -R deploy:deploy /opt/backups-repoThe cron job runs the script directly from /opt/infra/scripts/backup-sovereign.sh —
script updates take effect after the next git pull (automatic on every infra push).
The cron entry sets PATH=/usr/local/bin:/usr/bin:/bin so tools installed under
/usr/local/bin such as age are available in the non-interactive cron environment.
5. Test it
/opt/infra/scripts/backup-sovereign.sh
# Logs: ~/logs/sovereign-backup.logThe first run works against a truly empty backup repo: the script creates the initial
backup commit and pushes it to main. Later runs fetch and reset to origin/main
before adding the next dated backup so the local clone stays aligned with the remote.
ssh deploy@<VPS IP>
# See available backups:
ls /opt/backups-repo/sovereign/
# Restore (interactive — shows a plan and asks for confirmation):
cd /opt/infra
./scripts/restore-sovereign.sh 20260627-030000password manager
└── ~/.age/key.txt ← the one secret you protect manually
git (this repo)
├── env.example.yml ← template, committed, no secrets
├── env.yml ← YOUR real values, git ignored, never committed
├── apps/sovereign/.env.example ← deployment skeleton, committed, auto-synced from upstream
└── apps/sovereign/.env.enc ← age-encrypted, safe to commit
VPS
└── /opt/apps/sovereign/.env ← decrypted at deploy time, never in git
Operators edit env.yml, never apps/sovereign/.env directly — the latter is
generated output and gets overwritten on every run. scripts/generate-env.js is the
single tool for all three of the workflows below: it fetches the latest upstream
apps/sovereign/.env.example, renders apps/sovereign/.env from env.yml, validates
that nothing required is missing or still a placeholder, and encrypts the result to
apps/sovereign/.env.enc (via scripts/encrypt-env.sh).
node scripts/generate-env.js sovereign # fetch + render + encrypt
node scripts/generate-env.js sovereign --env-file env.yml # explicit env.yml path (default)
node scripts/generate-env.js sovereign --check # validate only, write nothing
node scripts/generate-env.js sovereign --no-fetch # skip the network fetch
If a required value is missing or a placeholder (YOUR_RUNTIME_DOMAIN, changeme, etc.)
survives into the render, the command fails with the list of offending variable names
— never values — and writes nothing.
npm install # once, installs js-yaml (scripts/generate-env.js)
cp env.example.yml env.yml # git ignored — never commit this copy
nano env.yml # fill in all required values
node scripts/generate-env.js sovereign
git add apps/sovereign/.env.example apps/sovereign/.env.enc
git commit -m "secrets: update sovereign"
git push origin main
# CI decrypts and installs on VPS automaticallynano env.yml # edit the one value you're rotating
node scripts/generate-env.js sovereign
git add apps/sovereign/.env.enc && git commit -m "secrets: rotate AUTH_SECRET"
git push origin main
# Apply immediately without a full deploy:
ssh deploy@<VPS IP> "cd /opt/apps/sovereign && docker compose up -d"Every normal (non---check, non---no-fetch) run fetches the latest upstream
.env.example and rewrites apps/sovereign/.env.example if it changed, preserving
everything below the # ══ deployment overrides line untouched:
node scripts/generate-env.js sovereign
git diff apps/sovereign/.env.example
git add apps/sovereign/.env.example apps/sovereign/.env.enc
git commit -m "chore: sync sovereign .env.example"--check never writes any file, including .env.example — use it only to validate
env.yml (e.g. in CI) without touching the working tree.
See docs/ports.md.
| Port | App |
|---|---|
| 4000 | sovereign-runtime |
| 4001 | sovereign-auth |
| 5000 | (next app) |
| Variable | Notes |
|---|---|
NEXT_PUBLIC_RUNTIME_URL |
Public runtime URL (e.g. https://example.com) |
AUTH_BASE_URL |
Public auth URL (e.g. https://auth.example.com) |
AUTH_COOKIE_DOMAIN |
Cookie shared across subdomains (e.g. .example.com) |
AUTH_WEBAUTHN_RP_ID |
Bare registrable domain (e.g. example.com) — changing invalidates passkeys |
AUTH_WEBAUTHN_ORIGIN |
Comma-separated origins for passkeys |
RUNTIME_PORT / AUTH_PORT |
Host ports (defaults: 4000 / 4001) |
POSTGRES_PASSWORD |
Generate: openssl rand -base64 32 |
COMPOSE_FILE |
docker-compose.prod.yml:docker-compose.postgres.yml |
BACKUP_GITHUB_REPO |
Backup destination (e.g. myorg/sovereign-backups) |
AGE_PUBLIC_KEY |
age1... — your age public key |
| Path | What it is |
|---|---|
/opt/infra/ |
git clone of this repo |
/opt/infra/caddy/ |
Caddy config (synced on every push to main) |
/opt/apps/sovereign/docker-compose.prod.yml |
Fetched from sovereign release |
/opt/apps/sovereign/docker-compose.postgres.yml |
Fetched from sovereign release |
/opt/apps/sovereign/docker-compose.override.yml |
Copied from infra repo (if present) |
/opt/apps/<name>/.env |
App secrets — NOT in git |
/opt/apps/<name>/.deploy-version |
Currently deployed tag |
/opt/backups-repo/ |
Clone of your backup repo |
~/logs/ |
Backup and app logs |
The deploy user doesn't have write access to /opt/apps — either it doesn't exist yet
or it's owned by root. Fix as root on the VPS:
mkdir -p /opt/apps
chown -R deploy:deploy /opt/appsThen re-trigger CI:
git tag sync-retry && git push origin sync-retryGit refuses to operate on a repo owned by a different user. Run as root:
git config --global --add safe.directory /opt/infraThis happens because the repo was cloned as root but CI pulls as deploy. The permanent
fix is to re-run bootstrap (which transfers the repo to deploy via chown).
Caddy is up but the sovereign containers aren't. Push a version tag to trigger the deploy job:
git tag v0.9.10 && git push origin v0.9.10If the tag already exists (e.g. from a previous failed attempt), delete and re-push:
git tag -d v0.9.10
git push origin :refs/tags/v0.9.10
git tag v0.9.10 && git push origin v0.9.10Note: the v* tag triggers the deploy when pushed to this repo, not sovereignfs/sovereign.
The sovereign repo tag triggers image builds; this repo's tag triggers the deploy.
Ubuntu 24.04 uses ssh.service. The bootstrap script handles this automatically.
If you see this on an older script version:
systemctl restart sshThe PWA service worker keeps the previous build's HTML in its cache. A hard refresh
(Cmd ⇧ R / Ctrl Shift R) bypasses the cache. From Sovereign v0.9.9+, the service
worker activates immediately on install — the window is narrower but can still occur.
# Check what's running
docker ps
# Logs
/opt/infra/scripts/logs.sh # sovereign-runtime, follow
/opt/infra/scripts/logs.sh auth # sovereign-auth
/opt/infra/scripts/logs.sh postgres # sovereign-postgres
/opt/infra/scripts/logs.sh caddy # caddy
/opt/infra/scripts/logs.sh runtime --tail 50 --no-follow
# Validate and reload Caddy
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
# Admin health check
curl -s -H "Authorization: Bearer $SOVEREIGN_ADMIN_KEY" \
https://example.com/api/admin/health | jq .
# Check deployed version
cat /opt/apps/sovereign/.deploy-version
# Prune old Docker images
docker image prune -f