Backup & Restore
The production stacks (docker-compose-studio-prod.yaml, and the Docker Swarm stacks) back up in two layers. Each runs from a container image of its own:
| Layer | Image | What it covers | Where the copy goes |
|---|---|---|---|
| Database backups | hrida-ai-studio-postgres | The main PostgreSQL database: users, chats, settings, Keycloak, and embeddings with pgvector | This server's MinIO by default, or any S3 bucket |
| Off-server backups | hrida-ai-studio-backup | Everything else: the files in MinIO (including the database backups), the app's data volume, LDAP, Grafana | An S3 bucket on another server, encrypted |
Both are off until you turn them on. Turn on both: the database backup alone stays on the same server, so it doesn't survive losing the server.
If you run Studio as one container (docker run) with its data in one volume, back up that volume as shown under "Backup & Restore" in Updating.
Database backups (WAL-G)
The postgres service runs hrida-ai-studio-postgres: the stock pgvector/pgvector:pg16 image plus WAL-G. Until you turn backups on, it behaves exactly like the stock image.
The backup protects against a lost or corrupted database volume. There's no failover or standby: while Postgres is down, Studio is down.
What it covers
| Data | Stored in | Backed up by WAL-G? |
|---|---|---|
| Users, chats, settings, artifacts and Keycloak's data | PostgreSQL | Yes |
Embeddings, with VECTOR_DB=pgvector | The same database | Yes |
Embeddings, with Chroma (the default VECTOR_DB) | The hrida-ai-studio volume | No: hrida-backup covers it |
| Uploaded files | MinIO, or the hrida-ai-studio volume | No: hrida-backup covers it |
By default the backups go to the stack's own MinIO, in the hrida-pg-backups bucket. That covers a lost database volume, but not a lost server or disk. For that, turn on hrida-backup, which copies the bucket to another server every hour, or point WAL-G at an off-server bucket (settings).
How it works
With STUDIO_PG_BACKUP=true (only the exact value true; anything else keeps the stock behaviour):
- Continuous archiving. Every change is shipped to the bucket within about 60 seconds, so at most about a minute of changes can be lost.
- Nightly full backups at 03:00, keeping the last 7. If there's no backup yet, the first runs as soon as Postgres is up, so the database is protected from the moment you turn this on, not from the next night.
- Automatic restore. When the container starts on an empty data volume, it fetches the latest full backup, replays the changes archived since, and continues as a normal read-write database.
- No silent fresh start. On an empty volume, if the bucket can't be reached within 120 seconds, the container refuses to start. It never creates an empty database where a backup could have been restored.
The one-off minio-init service creates the bucket, and Postgres creates it too if it's missing.
Settings
In .env:
| Variable | Default | What it does |
|---|---|---|
STUDIO_PG_BACKUP | (empty) | true turns backups on |
WALG_BACKUP_CRON | 0 3 * * * | When full backups run (cron syntax) |
WALG_RETAIN_FULL_BACKUPS | 7 | How many full backups to keep |
WALG_S3_PREFIX | s3://hrida-pg-backups/postgres | Where the backups go |
WALG_S3_ENDPOINT | http://minio:9000 | The S3 server |
WALG_S3_ACCESS_KEY, WALG_S3_SECRET_KEY | MinIO's root user | The S3 server's credentials |
WALG_S3_REGION | us-east-1 | The bucket's region |
STUDIO_POSTGRES_TAG | latest | The image tag |
On Docker Swarm, the stack's Postgres always writes its backups to the stack's MinIO; hrida-backup copies them off the server.
Turn it on
# In .env: STUDIO_PG_BACKUP=true (and MINIO_ROOT_PASSWORD, if it isn't set yet)
docker compose -f docker-compose-studio-prod.yaml pull postgres minio minio-init
docker compose -f docker-compose-studio-prod.yaml up -d postgres minio minio-init
docker logs -f hrida-postgres # wait for: backup-cron ... doneThe image is PostgreSQL 16, like pgvector/pgvector:pg16, so the existing postgres-data volume is reused as it is.
On Docker Swarm, set STUDIO_PG_BACKUP=true in .env, load it into the shell (set -a; . ./.env; set +a) and redeploy the stack as usual.
Check it's working
docker exec hrida-postgres wal-g backup-list # at least one base_... line
docker exec hrida-postgres wal-g wal-show # changes archived, with no gaps
docker logs hrida-postgres 2>&1 | grep -i "archive command failed" # should print nothingOn Docker Swarm, run these on the data node, with the container's name from docker ps.
Restore after data loss
-
Make sure the backups are intact: MinIO's data, or your off-server bucket. If the whole server is gone, restore MinIO first, as described in Whole server lost.
-
Stop the app and Postgres, remove the broken data volume and start Postgres on an empty one:
bashdocker compose -f docker-compose-studio-prod.yaml stop hrida-ai-studio hrida-ai-studio-worker postgres docker compose -f docker-compose-studio-prod.yaml rm -f postgres docker volume ls | grep postgres-data # the volume's exact name docker volume rm <project>_postgres-data docker compose -f docker-compose-studio-prod.yaml up -d postgres docker logs -f hrida-postgres # "restoring latest base backup", recovery, then ready -
Start the app again:
docker compose -f docker-compose-studio-prod.yaml up -d.
Rehearse it
A backup you have never restored is only a hope. Rehearse on throwaway containers, never on the real volumes:
- Run the image with backups on against a scratch MinIO, and add some rows.
- Force a full backup (
wal-g backup-push), then add more rows. - Delete the data volume and start the container again.
- Check that the rows from both before and after the full backup are back.
Repeat it after updating the image.
Off-server backups (hrida-backup)
The database backup covers only the main PostgreSQL database, and by default keeps it on the same server. The hrida-backup service runs hrida-ai-studio-backup and copies everything else to an S3 bucket on another server or provider, so your data survives losing the whole server.
What it covers
| Data | How | Encrypted | When |
|---|---|---|---|
MinIO buckets: hrida-studio-files, hrida-pg-backups (the database backups) and any others | Copied object by object through MinIO's S3 API, never from the raw volume | No (see below) | Hourly |
The hrida-ai-studio volume (/app/backend/data), except cache/ | Snapshot | Yes | 02:00 |
| The LDAP directory | LDIF export over the network | Yes | 02:00 |
The LDAP configuration (/etc/ldap/slapd.d) | Snapshot, plus a cn=config export if LDAP_CONFIG_PASSWORD is set | Yes | 02:00 |
Grafana: grafana-data and lgtm-data (with docker-compose.otel.yaml) | Snapshots | Yes | 02:00 |
postgres-secondary, if it runs and holds data | pg_dumpall | Yes | 02:00 |
| Keycloak | Stored in the main database, so the database backup covers it | — | — |
Not backed up: redis-data (Valkey's cache and queues), ollama (models can be pulled again), and prometheus-data, loki-data and tempo-data (telemetry).
The copied MinIO objects are stored as they are, so any S3 tool can restore them: protect the destination bucket with its own access policy. Everything else goes into a restic repository in the bucket's restic/ folder, compressed and encrypted with BACKUP_RESTIC_PASSWORD. It keeps 7 daily, 4 weekly and 6 monthly snapshots.
How it behaves
- Off by default. Without
BACKUP_ENABLED=truethe container logsdisabledand idles. If a setting is missing, the bucket doesn't exist or the restic password is wrong, it logs exactly what's wrong and idles as unhealthy. It never restarts in a loop, and no other service depends on it. - Refuses a destination on the same server. If
BACKUP_S3_ENDPOINTis this server's MinIO, it doesn't back up: a name likeminioorhrida-minio, a loopback address, the same host asBACKUP_MINIO_ENDPOINT, or a name that resolves to the same address. - The first backup runs at start, if there's none yet. After that, jobs follow their schedules, and a job never overlaps its own previous run.
- Read-only. The volumes it backs up are mounted read-only. Its logs go to
docker logs hrida-backup. - Health. The container turns unhealthy when a job's last success is older than 26 hours.
- Deleted files stay in the copy; changed files replace it. A file deleted from MinIO stays in the copy unless
BACKUP_MINIO_REMOVE=true. A file changed in place, for example encrypted by ransomware, overwrites its copy at the next hourly run. Turn on versioning on the destination bucket so earlier versions stay recoverable.
Because deletions aren't copied, the copy grows over time: it also keeps the old database backups that WAL-G removes. With versioning on the destination, you can set BACKUP_MINIO_REMOVE=true to keep the copy in step with MinIO; deleted and overwritten files then remain as earlier versions, which a lifecycle rule for noncurrent versions can expire.
Settings
In .env:
| Variable | Default | What it does |
|---|---|---|
BACKUP_ENABLED | (empty) | true turns backups on |
BACKUP_S3_ENDPOINT | — | The off-server S3 endpoint, for example https://s3.eu-central-1.amazonaws.com |
BACKUP_S3_BUCKET | — | The destination bucket; create it first |
BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY | — | A key that can read and write the bucket |
BACKUP_S3_REGION | us-east-1 | The bucket's region |
BACKUP_RESTIC_PASSWORD | — | Encrypts the snapshots. Generate it with openssl rand -base64 32 |
BACKUP_MINIO_CRON | 0 * * * * | When MinIO is copied (cron syntax); off turns the copy off |
BACKUP_VOLUMES_CRON | 0 2 * * * | When the volumes, LDAP and postgres-secondary are backed up |
BACKUP_KEEP_DAILY, BACKUP_KEEP_WEEKLY, BACKUP_KEEP_MONTHLY | 7, 4, 6 | How many snapshots to keep |
BACKUP_MAX_AGE_HOURS | 26 | The container turns unhealthy after this long without a successful run |
BACKUP_EXCLUDES | studio-data/cache | Paths left out of the snapshots, comma-separated |
BACKUP_MINIO_ENDPOINT | http://minio:9000 | This server's MinIO, the source |
BACKUP_MINIO_BUCKETS | (all) | Copy only these buckets, comma-separated |
BACKUP_MINIO_REMOVE | false | true also removes the copies of files deleted from MinIO |
BACKUP_PG_SECONDARY_HOST, BACKUP_PG_SECONDARY_USER, BACKUP_PG_SECONDARY_PASSWORD | postgres-secondary, postgres, — | The secondary database |
BACKUP_LDAP_URI | ldap://ldap:389 | The LDAP server; empty skips LDAP. The base DN comes from LDAP_DOMAIN |
STUDIO_BACKUP_TAG | latest | The image tag |
BACKUP_MEM_LIMIT | 512m | The container's memory limit |
Turn it on
-
Create a bucket on an S3 service on another server or provider (another MinIO, AWS S3, Backblaze B2, Wasabi…) and an access key that can read and write it.
-
In
.env, setBACKUP_ENABLED=true, theBACKUP_S3_*values andBACKUP_RESTIC_PASSWORD.Keep a copy of the password somewhere elseStore
BACKUP_RESTIC_PASSWORDand theBACKUP_S3_*values outside this server, in a password manager. Without the password nobody can read the encrypted backups, including you. -
Start it:
bashdocker compose -f docker-compose-studio-prod.yaml pull hrida-backup docker compose -f docker-compose-studio-prod.yaml up -d hrida-backup # With Grafana, include its overlay so its volumes are backed up too: docker compose -f docker-compose-studio-prod.yaml -f docker-compose.otel.yaml up -d hrida-backup docker logs -f hrida-backup
On Docker Swarm
docker-compose-common-app-prodSwarm.yaml runs hrida-backup on the node labelled hrida.data. Set the values in .env, load it into the shell (set -a; . ./.env; set +a) and redeploy the stack as usual.
- It backs up the data node's copy of the
hrida-ai-studiovolume; each node has its own. - It reads the Grafana and
ldap-configvolumes by their${VOLUME_PREFIX}_…names. - It exports LDAP from
BACKUP_LDAP_URI, by defaultldap://openldap:389; empty skips LDAP. - A volume that doesn't exist on that node shows as
empty — skippedin the log.
Check it's working
docker logs hrida-backup 2>&1 | tail -n 40 # "done in ...s — saved: ..." for both jobs
docker inspect -f '{{.State.Health.Status}}' hrida-backup # healthy
docker exec hrida-backup healthcheck.sh # the age of each job's last success
docker exec hrida-backup restore.sh list # the snapshots of each item, and the copied buckets
docker exec hrida-backup restore.sh check # integrity check; reads 5% of the data
docker exec hrida-backup backup-volumes.sh # back up the volumes now (backup-minio.sh: MinIO)unhealthy with a config error: line from healthcheck.sh means a setting needs fixing. STALE means scheduled runs are failing, and the logs say why.
Restore
Every restore runs in a one-off hrida-backup container that mounts the target read-write. restore.sh reads the same BACKUP_* values from .env, and works even while BACKUP_ENABLED is off.
The volume names in the -v options below are the names in the compose file. Compose maps them to the project's real volumes (for example hrida-ai-studio_hrida-ai-studio), so run the commands from the folder you deploy from, with the same -f files.
C="docker compose -f docker-compose-studio-prod.yaml" # add -f docker-compose.otel.yaml for Grafana
$C run --rm --no-deps hrida-backup restore.sh list # pick a snapshot id, or use the latest
# The running sidecar mounts the volumes, and Docker won't remove a volume
# that's in use, so take it down for the restore:
$C rm -sf hrida-backupWhen you're done, bring it back with $C up -d hrida-backup.
Whole server lost
Restore in this order:
- Set up the new server with the same deployment files and
.env, including all theBACKUP_*values, and create the network:docker network create hridaai-network. - MinIO first. Run
$C up -d minio minio-init, then restore the MinIO buckets. This also brings back the database backups inhrida-pg-backups. - Then Postgres. With
STUDIO_PG_BACKUP=true,$C up -d postgresstarts on an empty volume and restores itself from the database backups you just brought back (Restore after data loss). - Restore the volumes and LDAP, then start everything with
$C up -d.
MinIO buckets
$C up -d minio
$C run --rm --no-deps hrida-backup restore.sh minio --all # every copied bucket
$C run --rm --no-deps hrida-backup restore.sh minio hrida-studio-files # or just oneThis copies <BACKUP_S3_BUCKET>/minio/<bucket> back into the MinIO at BACKUP_MINIO_ENDPOINT, creating the bucket if needed. Objects with the same name are overwritten, and nothing is deleted. To restore into another MinIO, pass RESTORE_MINIO_ENDPOINT, RESTORE_MINIO_ACCESS_KEY and RESTORE_MINIO_SECRET_KEY with -e.
App data and Grafana volumes
Stop the services that use the volume, empty it (or start from a new volume), restore, and start the services again:
$C stop hrida-ai-studio hrida-ai-studio-worker
$C rm -f hrida-ai-studio hrida-ai-studio-worker
docker volume rm <project>_hrida-ai-studio # exact name: docker volume ls | grep hrida-ai-studio
$C run --rm --no-deps -v hrida-ai-studio:/restore hrida-backup restore.sh volume studio-data /restore
$C up -d hrida-ai-studio hrida-ai-studio-workerGrafana works the same way: add -f docker-compose.otel.yaml to $C, stop grafana, and restore with -v grafana-data:/restore … volume grafana-data /restore and -v lgtm-data:/restore … volume lgtm-data /restore.
- To restore an older snapshot, add its id after the target.
--forcerestores into a volume that isn't empty, overwriting files in place.- File owners and permissions are restored as they were.
cache/isn't backed up; Studio downloads its models again when it needs them.
LDAP
The LDIF export holds the directory's entries; the ldap-config snapshot holds slapd's configuration and schema. LDAP must be stopped, because slapadd writes the database files directly.
$C stop ldap phpldapadmin && $C rm -f ldap
docker volume rm <project>_ldap-data <project>_ldap-config
# 1. The config volume, from its snapshot
$C run --rm --no-deps -v ldap-config:/restore hrida-backup restore.sh volume ldap-config /restore
# 2. The directory as LDIF, parents first, into a folder on the host
mkdir -p ./restore
$C run --rm --no-deps -v "$PWD/restore:/out" hrida-backup restore.sh ldap-ldif /out/ldap-data.ldif
# 3. Load it into the empty ldap-data volume, and give the files to slapd's user
$C run --rm --no-deps -v "$PWD/restore:/in:ro" --entrypoint sh ldap -c \
'slapadd -F /etc/ldap/slapd.d -b "dc=hrida,dc=local" -l /in/ldap-data.ldif && chown -R openldap:openldap /var/lib/ldap'
$C up -d ldap phpldapadminReplace dc=hrida,dc=local with your LDAP_DOMAIN: example.com is dc=example,dc=com.
If there's no ldap-config snapshot (for example on Docker Swarm, where LDAP's volumes aren't on the data node), do this instead of step 1:
- Start
ldaponce on empty volumes, with the sameLDAP_DOMAINandLDAP_ADMIN_PASSWORD, so it creates a fresh configuration. - Stop it and empty only the
ldap-datavolume. - Continue from step 2 above.
restore.sh dump ldap-config-ldif <file> writes the cn=config export, to compare or re-apply custom configuration.
postgres-secondary
# Replay the dump into a running, empty server:
$C run --rm --no-deps -e RESTORE_PG_HOST=postgres-secondary \
-e RESTORE_PG_USER=postgres -e RESTORE_PG_PASSWORD=... hrida-backup restore.sh pg-secondary
# Or only write the SQL file:
$C run --rm --no-deps -v "$PWD/restore:/out" hrida-backup restore.sh dump pg-secondary /out/pg-secondary.sqlThe dump comes from pg_dumpall, so it also creates the roles and databases. A role "postgres" already exists error is expected and harmless.
Rehearse it
docker/backup/rehearsal/rehearse.sh, in Studio's repository, runs the whole round trip on throwaway containers and volumes only. They live in the compose project hrida-backup-rehearsal and are deleted at the end. The script:
- Seeds a source MinIO, LDAP, a secondary database and test volumes.
- Checks that the sidecar stays idle when it's off; refuses missing settings, a destination on the same server (by name and by address) and a wrong restic password; and never restarts in a loop.
- Lets the first backup run on its own, then checks that:
- every snapshot exists;
- the destination holds no plain text;
- an overlapping run is skipped;
- the health check fails when the last success is older than 26 hours;
- a stopped secondary database is skipped cleanly.
- Deletes all the source data, restores everything with
restore.sh, and compares fingerprints: object hashes, file hashes with owners and permissions, row checksums and LDAP entries.
docker login ghcr.io # the MinIO image comes from GHCR (or set REHEARSAL_MINIO_IMAGE)
bash docker/backup/rehearsal/rehearse.sh # KEEP=1 leaves the stack up afterwardsRun it again after updating the backup image.
Notes
postgres-secondaryis not a replica. Indocker-compose-studio-prod.yamlit's a separate database that doesn't copy the main one, so it's no standby. It has noPOSTGRES_PASSWORD, so it can't start on an empty volume. hrida-backup dumps it only when it accepts connections and holds data; otherwise the log sayspg-secondary: ... skipped. If it was set up by hand with a password, setBACKUP_PG_SECONDARY_USERandBACKUP_PG_SECONDARY_PASSWORD. To replay a dump into it, give it a password first, or restore into any other PostgreSQL 16 server.- Keycloak keeps its data in the main database (
KC_DB_URL_HOST=postgres), so the database backup covers it. On Docker Swarm, Keycloak is defined indocker-compose-common-enterprise-Studio.yaml: check that itsKC_DB_URL_HOSTpoints at the same Postgres.
Related
- Container Images: the images, their tags and how to pin them
- Deployment & Scaling: PostgreSQL, Redis and multiple replicas
- Updating: updating Studio and rolling back