Production deployment
A real deployment never builds on the box. It pulls tagged images from GHCR
and applies docker-compose.prod.yml, which is what binds the api and frontend
to 127.0.0.1, removes the Postgres, Redis, search and optional-profile port
publishes entirely, and adds restart policies, log caps, resource limits and
TLS.
Images are ghcr.io/yegamble/{vidra-core,vidra-user,vidra-search}:vX.Y.Z, built
only from tags. The newest release is on the
vidra-core releases page, and
each release's component pairing is a record under
releases/.
The env file
env/production.env is generated by vidra setup and is docker-compose
format, not shell.
source env/production.envvidra setup generates VIDRA_COMPOSE_PROFILES=core frontend — unquoted, with
a space. Compose's --env-file parser reads that as the literal string
core frontend, which is correct. A shell that sources the same file sets
VIDRA_COMPOSE_PROFILES=core and then tries to execute frontend.
Parse it as KEY=VALUE with the value taken literally, or hand it to
docker compose --env-file. The same applies to any value with a space, such
as a multi-word INSTANCE_NAME.
The failure is quiet, too: a set -e script that pipes its output to tail
reports the pipeline's exit status, so a run that died on line 1 exits 0 and
looks like a run that found nothing to do.
Set it up by hand — this is exactly what the installer automates:
git clone https://github.com/yegamble/vidra.git /opt/vidra && cd /opt/vidra
./bootstrap.sh # clones the three component repos
vidra setup --template env/production.env.example
# asks for the domain, TLS mode, instance name,
# release tag, storage, SMTP and registration;
# writes env/production.env AND deploy/Caddyfile.local
git check-ignore -v env/production.env # MUST match, or stop and fix .gitignore
./deploy/deploy.sh # or: vidra deploy
curl -fsS http://127.0.0.1:8080/readyz # {"status":"ok"} incl. postgres + redis
curl -fsS https://example.com/ # Caddy + certificate + frontend
nmap -Pn -p 5432,6379,8080,3000 <host-ip> # must all be closed
You do not hand-edit any secret. vidra setup mints JWT_SECRET,
MFA_KEY_KEK, POSTGRES_PASSWORD, REDIS_PASSWORD and
SEARCH_INTERNAL_SECRET and writes the file mode 0600. Scripted installs pass
the same answers as flags; see Operator CLI.
cp the template to env/production.env firstvidra setup reads env/production.env back before it writes it, and a copy of
the template counts as an existing configuration. On anything but a first
install it refuses to mint a blank key-encryption key, because on a live
database a fresh one would orphan every sealed row, so it stops with
MFA_KEY_KEK is blank in the configuration being merged. If you already copied
the template and have never run a deploy, delete the copy and run vidra setup
as above.
Run ./deploy/deploy.sh for the first deploy too, not hand-run migrate and
up -d. On a fresh host it skips the pre-deploy dump because there is no
database yet, and says so; everything else still runs. deploy/compose.sh is
for reading and stopping — it gates nothing, so a run --rm migrate followed by
up -d through it skips the Compose version check, the migrator tag floor, the
Caddyfile and DNS checks, the independent ledger assertion against the expected
migration version, and the health probes.
deploy.sh refuses while deploy/Caddyfile.local is missing, still says
example.com, or serves a different host than PUBLIC_BASE_URL. It also
refuses an ACME deploy whose domain does not yet resolve to this host, because
Let's Encrypt rate-limits failures; VIDRA_SKIP_DNS_PREFLIGHT=1 overrides.
Federation is off after setup, and the interview does not ask about it. To turn it on, see Turn federation on after install.
POSTGRES_PASSWORD on an existing volumePostgres reads that variable only at initdb. If the data volume already
exists, put the database's current password in the env file, not a freshly
generated one, and rotate with ALTER ROLE … PASSWORD inside the container
first.
Every deploy after the first
./deploy/deploy.sh # or: vidra deploy, or: make deploy
deploy.sh is the gated pipeline, and the ordering is the point:
- Refuse below Compose 2.24. Silent failure mode: the
!resettags are ignored, the deploy reports success, and the database is on the public internet. - Pin nested checkouts to
VIDRA_*_TAG, so the migrators run the matching schema versions. - Integrity-checked pre-deploy
pg_dump, which aborts the deploy on failure. A deploy with no dump behind it is not a deploy you can undo. - Pull before stop. The old release keeps serving while images download.
- Two exit-code-gated migrators, run as separate steps so you can see which
one failed, with the final ledger state asserted against
vidra-core/migrationsincludingdirty=false. up -d --no-build, then the Caddy reload.- Health probes — api, frontend and the edge.
It is mode-aware: VIDRA_TLS_MODE decides which of those still apply, and
every skip is printed with its reason. If you did not read a skip line, the
check ran.
Failed probes leave the broken release running. deploy.sh tells you; it does
not undo. Rolling back is a deliberate operator action — see
Upgrading and rollback.
The restart window
Step 4 recreates the api and frontend containers, so both are briefly gone.
Caddy buffers that window rather than exposing it: lb_try_duration holds an
arriving request and re-dials every 250 ms — 30 s for the api, 45 s for the
frontend, which cannot start until the api is healthy. Callers get a slow
response instead of a 502.
What that is not: it is not blue-green. There is one instance of each service
and nothing serves while the replacement boots; the wait is merely spent inside
Caddy. Requests already in flight are not covered — they are mid-response on the
old container when it gets SIGTERM, so their fate is the graceful drain
(HTTP_SHUTDOWN_TIMEOUT, 20 s by default, inside a 30 s stop_grace_period).
Past the duration it is a 502 again: the buffer covers a normal recreate, not a
boot-looping api.
TLS topologies
VIDRA_TLS_MODE is not only a certificate setting — it decides whether this host
runs an edge at all.
| Mode | Who terminates | What the deploy still checks |
|---|---|---|
acme (default) | Managed Caddy, Let's Encrypt | Everything |
acme-staging | Managed Caddy, LE staging | Everything |
internal | Managed Caddy, its own local CA | Everything |
external | Your proxy, load balancer or CDN | Caddy is not in the project at all; the Caddyfile check, the reload and the DNS preflight are skipped, and the edge probe becomes a warning |
plain-http | Nobody | Caddy runs as a plain-HTTP site; require_real_domain and DNS preflight are skipped |
With external, you own four things
- Routing. Forward
/api/*,/healthz,/readyz,/version,/sitemap.xml,/feeds/*,/nodeinfo/*and/.well-known/*to the api on127.0.0.1:${HTTP_PORT}(default 8080); everything else to the frontend on127.0.0.1:${FRONTEND_PORT}(default 3000).vidra setupwritesdeploy/nginx-external.conf.examplemirroring the split exactly — start from it. A hand-written proxy that sends/feeds/*to the frontend 404s every feed link and nothing errors. - Headers.
X-Forwarded-Proto: httpsandX-Forwarded-For. Without the first, the api believes it is serving plain HTTP and mintshttp://links. - Upload limits and timeouts. Your proxy's body-size limit is now the upload limit. nginx's default is 1 MB.
TRUSTED_PROXY_CIDRS, but only when the terminator has a public IP. The api already trusts loopback, private and link-local sources. List only ranges you control — trusting a range you do not own lets anyone in it forge the header.
plain-http puts everything in the clearPUBLIC_BASE_URL must be http://, and because the api applies production
validation rules whatever VIDRA_ENV says, that origin is a hard refusal until
you also set VIDRA_ALLOW_PLAIN_HTTP=true. That switch is the consent, and it
is what turns off Secure cookies and HSTS. Every credential, cookie and upload
crosses the network in the clear. Use it behind a VPN, on an isolated network,
or through an SSH tunnel — never on anything the internet can reach. Federation
and OAuth are https-only by design and will not work there.
Backups
./deploy/backup.sh, and the systemd timer that runs it, writes two files
per run under the same UTC stamp:
| File | Contents | Kept |
|---|---|---|
backups/vidra-<UTC>.dump.gz | Custom-format pg_dump, verified with pg_restore -l before it is kept | 14 daily + 8 weekly |
backups/vidra-config-<UTC>.tar.gz | env/production.env + deploy/Caddyfile.local, repo-relative, mode 0600 | 14 daily + 8 weekly |
plus a backups/last_success marker, whose format is a contract vidra doctor
reads to judge backup age against a 26-hour window.
The config archive is why a dump is restorable at all. env/production.env
holds MFA_KEY_KEK, which is generated once and is not derivable again —
restore a database without it and every user's second factor is undecryptable.
deploy/Caddyfile.local is gitignored and existed only on the host that died.
backup.sh in v0.7.5 and earlier reads BACKUP_RCLONE_REMOTE, BACKUP_S3_URI,
BACKUP_S3_ENDPOINT and HEALTHCHECKS_URL only from the process
environment, not from env/production.env. The shipped
vidra-backup.service sets only ENV_FILE, so a host that put them in the env
file has been taking local-only, unmonitored backups. The journal says so on
every run: journalctl -u vidra-backup.service | grep 'LOCAL COPY ONLY'.
BACKUP_AGE_RECIPIENTS does not exist in v0.7.5, so the only way to encrypt
off-site copies there is an rclone crypt remote.
To get off-site backups on v0.7.5, put the values in a root-owned file and load it with a systemd drop-in:
sudo install -d -m 0700 /etc/vidra
sudo install -m 0600 /dev/null /etc/vidra/backup.env
sudoedit /etc/vidra/backup.env # BACKUP_RCLONE_REMOTE=... HEALTHCHECKS_URL=...
sudo systemctl edit vidra-backup.service
# [Service]
# EnvironmentFile=/etc/vidra/backup.env
sudo systemctl daemon-reload
sudo systemctl start vidra-backup.service && journalctl -u vidra-backup.service -n 30
From v0.7.7 the script reads these keys from the env file, so the upgrade starts uploading whatever the env file names. Set up encryption before you upgrade: see Upgrading from v0.7.5. A value in the process environment, including an empty one, still wins over the env file, so a drop-in you leave in place keeps working.
- Off-site is opt-in. Set
BACKUP_RCLONE_REMOTE, orBACKUP_S3_URIplusBACKUP_S3_ENDPOINT, inenv/production.env(v0.7.7 and later; see the warning below for v0.7.5). Use a bucket in a different region from the media Space. Both files go to the same target, deliberately. - Encrypt the off-site copies. The config archive holds
JWT_SECRET, every key-encryption key and the database and S3 passwords, so a plaintext upload hands all of it to anyone who can read the bucket. From v0.7.7,backup.shrefuses to upload the config archive in plaintext unless one of three things holds:BACKUP_AGE_RECIPIENTSis set, the rclone remote istype = crypt, or you have setBACKUP_OFFSITE_PLAINTEXT=trueto accept the risk. When it refuses, the dump still uploads, the config archive does not, and the run exits non-zero, so systemd marks the unit failed and/failis pinged if you setHEALTHCHECKS_URL. Before v0.7.7 the script uploaded plaintext and logged aWARNINGon every run. Set the variable to one or more age public keys (age1..., space- or comma-separated) and both files are encrypted to them before upload and land off-site as<name>.age. Local copies stay plaintext, sorestore.shreads them unchanged. The host needsageinstalled: if it is missing, or an encryption fails, the run aborts (and pings/fail, if you setHEALTHCHECKS_URL) rather than uploading plaintext. Generate the pair withage-keygen -o key.txt, put theage1...public key in the env file, and keepkey.txtoff this server (a password manager or offline media). A private key stored on the host it protects defeats the point, and without it the off-site backups cannot be recovered. If your rclone remote is already acryptremote you can skip this, andBACKUP_OFFSITE_PLAINTEXTis not needed. - Local-only is a decision you record.
vidra doctorwarns under "off-site backup" while neitherBACKUP_RCLONE_REMOTEnorBACKUP_S3_URIis set. If keeping backups on the host is deliberate, setBACKUP_OFFSITE_ACK=trueto silence it. Only the doctor reads that flag;backup.shignores it. The check is a warning, never a failure, so it does not change the exit code, and withVIDRA_EXTERNAL_POSTGRES=trueit is skipped. - Alert on a missing backup, not just a failing one. Set
HEALTHCHECKS_URL; the script pings/start,/failon error, and success at the end, so a host that stops running the timer at all still pages you. - Redis needs no backup. It is a cache and rate-limit store and may be flushed at any time.
- Media is in neither file. See Storage and media.
- With
VIDRA_EXTERNAL_POSTGRES=true, bothbackup.shandrestore.shrefuse outright. Use your provider's backups and PITR — and back upenv/production.envanddeploy/Caddyfile.localyourself, because such a host gets no config archive either.
Prove it before you need it:
sudo ./deploy/provision.sh --yes # installs AND verifies the timer
sudo -u vidra ./deploy/backup.sh # prove it works
sudo reboot # confirm every container returns
Then prove the dump restores, on a scratch host, never this one: copy the
dump there and run ./deploy/restore.sh backups/<latest>.dump.gz on that
host. restore.sh drops and replaces the database of the host it runs on, so
run it bare first: it refuses, and prints which database it would drop and from
which dump. Only after reading that, re-run it with --yes. Keep
RESTORE_CONFIRM=<database name> for scripted drills on throwaway hosts; pasted
onto a live host, it skips that refusal.
enable --now on a timer whose .service fails to parse can still exit 0, and
that failure surfaces as "no backups have ever run" six weeks later, during a
restore. provision.sh verifies with systemctl is-enabled / is-active for
that reason, and vidra doctor checks the timer and the age of
backups/last_success.
Run a restore drill quarterly.
Disaster recovery, in the non-obvious order
restore.sh refuses to run while deploy/Caddyfile.local is missing — the
prod compose file mounts it, and a missing bind-mount source is created by Docker
as an empty directory, which crash-loops Caddy with every app container
perfectly healthy. So configuration lands before the database:
# 1. Fetch BOTH files for the SAME stamp from off-site, into a scratch directory
# that is NOT /opt/vidra (the installer refuses a non-empty one).
mkdir -p ~/dr && cd ~/dr
rclone copy "$BACKUP_RCLONE_REMOTE/vidra-config-<stamp>.tar.gz" .
rclone copy "$BACKUP_RCLONE_REMOTE/vidra-<stamp>.dump.gz" .
# If the backups were encrypted (BACKUP_AGE_RECIPIENTS), the off-site names
# end in .age. Fetch those instead, then decrypt with the private key you
# kept off the old server. restore.sh and tar need the plaintext files:
# age -d -i key.txt -o vidra-config-<stamp>.tar.gz vidra-config-<stamp>.tar.gz.age
# age -d -i key.txt -o vidra-<stamp>.dump.gz vidra-<stamp>.dump.gz.age
# 2. Find the release the dump was taken under. The archive's env file pins it;
# read it without extracting anything.
tar -xzOf vidra-config-<stamp>.tar.gz env/production.env | grep '^VIDRA_.*_TAG='
# 3. The release tree and the CLI at THAT tag, WITHOUT the setup interview: it
# would mint a fresh JWT secret and fresh *_KEK values, and over the restored
# database that makes every sealed column unreadable.
curl -fsSL https://raw.githubusercontent.com/yegamble/vidra/main/install.sh \
| sh -s -- --yes --no-setup --ref <VIDRA_CORE_TAG from step 2>
# A git checkout install: add --git.
# 4. Then the host: AFTER the install, never before. install.sh refuses a
# non-empty /opt/vidra. provision.sh from v0.7.7 creates /opt/vidra/backups;
# the tree at an older tag does not, so create it (harmless if it exists).
cd /opt/vidra && sudo ./deploy/provision.sh --yes
sudo install -d -m 0700 -o vidra -g vidra /opt/vidra/backups
sudo install -m 0600 -o vidra -g vidra ~/dr/vidra-* backups/
# 5. Configuration FIRST, as the service user, so the 0600 env file is its.
sudo -u vidra tar -xzf backups/vidra-config-<stamp>.tar.gz -C /opt/vidra
# A git checkout install only: git check-ignore -v env/production.env (MUST match)
# 6. Bring up just enough to restore into, then restore. As the service user:
# it can read the env file and it is the one in the docker group.
sudo -u vidra ./deploy/compose.sh up -d postgres
sudo -u vidra ./deploy/restore.sh backups/vidra-<stamp>.dump.gz # refuses; read it, then add --yes
# 7. Media, if STORAGE_BACKEND=local.
# 8. Point DNS at the new host, then: sudo -u vidra ./deploy/deploy.sh
If provision.sh already ran on the new host (cloud-init, for example), the
install in step 3 refuses because /opt/vidra/backups exists: move it aside,
install, then move it back. install.sh --no-setup is fetched from the
installer's main, so it is available whichever release you restore.
The env file pins VIDRA_*_TAG, so the rebuilt host comes back on the release
the dump was taken under — which is what you want, and is worth reading rather
than assuming.
Migration failed mid-deploy
Both migrators drive golang-migrate as a library inside the service binary,
with the SQL compiled into the image. That library marks its ledger dirty=true
when a migration fails part-way and then refuses every subsequent up with an
opaque error, and because the api gates on the migrator completing successfully,
the site stays down.
There are two independent ledgers: public.schema_migrations (core) and
public.vidra_search_migrations (search). Either can go dirty without the other.
# 1. Find out where it stopped. Note the REPEATED word: `run <service> <args>`
# REPLACES the service's command, so the subcommand must be restated.
./deploy/compose.sh run --rm migrate migrate version
./deploy/compose.sh run --rm search-migrate migrate version
# 2. Work out what that migration did before it failed and undo the partial
# effect BY HAND. golang-migrate does not roll back for you.
./deploy/compose.sh exec postgres psql -U vidra -d vidra
# 3. Point the ledger at the last CLEAN version, N-1. `force` stamps the version
# and clears dirty WITHOUT running any SQL — it is an assertion about the
# schema you just repaired. --yes-i-know is checked before the database is
# touched, so a refusal means nothing happened.
./deploy/compose.sh run --rm migrate migrate force 41 --yes-i-know
# 4. Re-run the deploy, not the bare migrator. It takes a fresh dump, runs both
# migrators as separate gated steps, and asserts the ledger is clean and at
# the expected version before it starts anything.
./deploy/deploy.sh
Never point the ledger at N — that claims the broken migration succeeded, and
the next deploy builds on a schema that does not exist. force -1 is the right
target when it was the first migration that died.
None of the up-migrations use CREATE INDEX CONCURRENTLY and none sets
lock_timeout, so a data-dependent migration against a populated table stalls
rather than fails, with api boot gated behind it. Look for a blocking lock in
pg_stat_activity and pg_locks before assuming it died.
Schema compatibility is enforced, not just documented
Release N−1's code must run against release N's schema. That is what makes a rollback a 60-second tag flip that never touches the database, and it is why a true schema rollback is "restore the pre-deploy dump" rather than "migrate down".
make ci in both Go repos runs scripts/migrate-lint.sh, which rejects
destructive forward DDL — DROP TABLE/COLUMN, RENAME, TRUNCATE,
SET NOT NULL, ALTER … TYPE, DELETE FROM — in any *.up.sql. Down
migrations are exempt, since they are the rollback path, and
-- migrate-lint:allow is the escape hatch for an accepted break. On top of
that, vidra-core's schema-compat workflow applies HEAD's migrations to a fresh
database and runs the previous release tag's integration suite against it.
Content-Security-Policy
The frontend enforces a Content-Security-Policy on every response, built per
request in proxy.ts (lib/security-headers.ts in vidra-user). In brief:
- Scripts run only with the per-request nonce, plus
'self'and the API origin for older browsers.object-srcis'none', andbase-uriandform-actionare'self'. - Stylesheets come from this site and the API origin; inline
styleattributes are allowed. - Fonts come from this site or
data:URIs only. - Images, media and network connections (
connect-src) may still reach anyhttp:orhttps:origin, so a third-party image or API call is not affected. Anything the policy does not name, such as an iframe, falls back todefault-src 'self'.
Custom JavaScript and CSS
The admin-supplied custom.js and custom.css are served by the API
(/api/v1/instance/custom.js and /api/v1/instance/custom.css), and the policy
allows them from this site and the API origin. What they pull in from anywhere
else is blocked: a webfont from a third-party host, an @import of a
third-party stylesheet, or a <script> tag pointing at an outside host. Scripts
are trusted by nonce rather than by host list ('strict-dynamic'), so a script
that custom.js itself creates at runtime inherits its trust in current
browsers. Do not rely on that: host fonts and stylesheets on this site or the
API origin.
Finding violations
Each violation is logged by the frontend as a csp violation line carrying the
violated directive, the blocked origin (an origin only, never a full URL) and
the document path (no query string):
vidra logs frontend | grep 'csp violation'
The endpoint that receives browser reports (/csp-report) is unauthenticated, so
it caps each report at 8 KiB and each process at 100 log lines a minute. A
flood of violations is therefore truncated, not complete.
If the policy breaks your custom code
Set CSP_REPORT_ONLY=true in env/production.env, then run vidra deploy. No
rebuild is needed. Do not use vidra restart: it is docker compose restart, which keeps the environment the container was created with, so the
change would not apply. deploy.sh ends with up -d --no-build, which recreates
any service whose configuration changed, and docker-compose.yml passes
CSP_REPORT_ONLY through to the frontend.
The same policy is then sent as Content-Security-Policy-Report-Only, so nothing
is blocked and violations are still logged. Fix the custom code, then remove
the line from env/production.env and run vidra deploy again to enforce the
policy once more.
Framing
Pages under /embed are meant to be embedded and are frameable by any site. Every
other page sends frame-ancestors 'self', so other sites cannot frame the rest
of the app.
Email
- DigitalOcean blocks outbound port 25 on new accounts. Use a relay
(Postmark, SES, Mailgun, Resend, Fastmail) on port 587 with STARTTLS and
AUTH, which is what
SMTP_PORT=587already defaults to. SMTP_FROMmust be a domain you control, with SPF and DKIM published for the relay, plus DMARC once those pass. Without them, password-reset mail lands in spam and users conclude the site is broken.MAIL_ENABLED=truerequiresSMTP_HOSTandSMTP_FROM; the api refuses to boot in production otherwise.
DEV_MAIL_CAPTURE_ENABLED=true in productionIt exposes GET /api/v1/dev/email-token, which returns a live password-reset
token for any address. It is gated in config validation, gated at route
registration, and 404'd at the edge. Do not go looking for a fourth way around
it. The same goes for HTTP_IMPORT_ALLOW_PRIVATE_URLS=true: both are set in
env/qa.env.example on purpose and must never be copied into a production env
file.
Staging is production config with throwaway data
Promote by deploying the exact image tags staging validated:
deploy.sh and rollback.sh both work from VIDRA_CORE_TAG, VIDRA_USER_TAG
and VIDRA_SEARCH_TAG in the env file, so promotion is copying three lines.
The same image serves every environment. The frontend container reads
PUBLIC_API_BASE_URL and serves it to the browser as /runtime-config.js, so
the origin is a restart, not a rebuild.
All the compose-based Make targets honour PROD_ENV_FILE=env/staging.env, and
every deploy/*.sh honours ENV_FILE=env/staging.env.