Documentation

Upgrade

For the compose stacks: the quick start (docker-compose.yml at the repository root) and the production compose. Kubernetes: docs/deploy/on-premise.md.

Three things have versions, and each rolls back on its own:

What Pinned by Changes on upgrade Rollback
Images (gateway, control plane) GUARDSPEC_VERSION in .env Code Previous tag
Database schema migrations/ of the checkout; applied by the migrate job on every up Only forward Restore the pre-upgrade backup
Models Each image's models/manifest.yaml, or L2_MODEL / L3_MODEL in .env A release may recommend a newer model L2_MODEL=name@old-version

Before 1.0, a minor version can change the schema and the configuration: read the CHANGELOG for every version you skip.

Backups

scripts/backup.sh saves what a restore needs, while the stack runs:

scripts/backup.sh                                                     # quick start
scripts/backup.sh -f deploy/compose/production/docker-compose.yml    # production
# backup: done: backups/guardspec-20261106T060839Z
File Contents Why it matters
guardspec.dump pg_dump -Fc of the database: organizations, users, API keys (hashes), policies, upstreams, alert rules, audit rows, schema_migrations The data
guardspec.env The .env the stack ran with POSTGRES_PASSWORD and GUARDSPEC_APP_PASSWORD; JWT_SECRET (without it every session is invalid; users log in again); MFA_ENC_KEY, the only key that encrypts data at rest (TOTP 2FA secrets, AES-256-GCM): without it every user's 2FA must be reset; and the secrets the database names but does not hold: UPSTREAM_KEY_* / UPSTREAM_LLM_KEY (upstream api_key_env), OIDC_SECRET_* (SSO client_secret_env), alert channel secrets (secret_env), SMTP and bot tokens
caddy-data.tgz Caddy's /data (production compose only) Certificates, the ACME account, and with CADDY_TLS=internal the root CA your clients trust
MANIFEST Time, images, migration version, row counts, SHA-256 of each file What the backup is, and an integrity check for restore

Not included, on purpose: models (pulled again and verified against the manifest, or pinned with L2_MODEL), images (pulled by tag), and secrets kept outside .env (a secret manager: back those up there).

The directory is created with mode 700 and holds every secret: encrypt it (age, gpg, your backup tool's encryption) and keep it off the host. API keys are stored as SHA-256 hashes, so the backup cannot reveal them; password hashes are bcrypt.

Schedule it with cron or a systemd timer, for example daily:

15 3 * * * cd /opt/guard-spec && scripts/backup.sh -f deploy/compose/production/docker-compose.yml -o /var/backups/guardspec/$(date +\%F) && find /var/backups/guardspec -mindepth 1 -maxdepth 1 -mtime +14 -exec rm -rf {} +

Restore

Into an empty database only (a new host, a new compose project, or after docker compose down -v); a database with tables is refused:

scripts/restore.sh -f deploy/compose/production/docker-compose.yml backups/guardspec-20261106T060839Z

It checks the dump's SHA-256, writes the backup's .env when there is none (and refuses a different MFA_ENC_KEY in an existing one), starts Postgres, creates the cluster-wide role guardspec_app (a database dump does not carry roles), runs pg_restore, puts Caddy's data back, then starts the stack: migrate applies any migrations newer than the dump and db-roles sets the guardspec_app password from .env. Logins, 2FA, API keys and the internal CA keep working.

Checked on 2026-11-06 (arm64, Docker Desktop): a backup of a production compose stack (1 organization, 1 user, 1 API key, 1 policy, 26,004 audit rows) restored into a separate compose project. Checksums of the organizations, users, API keys, policies and audit rows matched; the old admin password and API key worked; an injection was blocked and a benign prompt passed over HTTPS verified with the original Caddy root.

Managed databases: docs/managed-postgres.md.

Upgrading

scripts/backup.sh [-f ...]                          # 1. a backup to roll back to
git fetch --tags && git checkout v<new-version>     # 2. new migrations/ and compose files
# 3. .env: GUARDSPEC_VERSION=<new-version>; compare .env.example for new settings
docker compose [-f ...] pull                        # 4. images first, so the switch is short
docker compose [-f ...] up -d --wait                # 5. migrate and db-roles run before the services
curl -s localhost:8080/readyz | jq .detector        # 6. or https://<GATEWAY_HOST>/readyz

Rolling back

Images, no schema change

When the release you leave added no migration (compare migrations/ of the two tags, or MANIFEST's migration version with the new one):

git checkout v<old-version>
# .env: GUARDSPEC_VERSION=<old-version>
docker compose [-f ...] up -d --wait

Images and schema

A newer schema with older code is not supported, and the *.down.sql migrations drop what the newer version added, data included; they are not the tested path. Restore the backup taken before the upgrade instead:

docker compose [-f ...] down -v      # removes the upgraded database: keep the backup safe first
git checkout v<old-version>
# .env: GUARDSPEC_VERSION=<old-version> (restore.sh keeps an existing .env)
scripts/restore.sh [-f ...] backups/guardspec-<before-upgrade>

Audit rows written between the backup and the rollback are lost. To keep them, export them first (GET /v1/organizations/{id}/requests, or a pg_dump -t requests).

down -v also removes the models volume of the production compose (pulled again) and Caddy's data (restored from the backup).

Models only

No image or schema change: pull the old version and pin it.

docker compose [-f ...] --profile models run --rm models-pull guardspec-classifier@<old>
# .env: L2_MODEL=guardspec-classifier@<old>
docker compose [-f ...] up -d gateway
curl -s localhost:8080/readyz | jq .detector.l2_model

Without a restart: keep the pin in .env for the next start, and reload the running gateway with POST /admin/models/reload and {"l2_model":"guardspec-classifier@<old>"} (needs GATEWAY_ADMIN_TOKEN). SIGHUP reloads the targets the gateway already has, not a changed .env.

Details, the L3 embedder and air-gapped installs: docs/models.md.

A failed upgrade

Source: docs/upgrade.md @ ca69dad29f02