Setup
Backups & restore
What's stateful in a Tyto deployment, how to back it up consistently, and how to restore or migrate to a new host.
Everything a Tyto server needs to recover lives in a handful of named Docker
volumes on the core stack, plus the .env file that configures it. The
client stack holds no application data at all.
What’s stateful
| Volume | Contents | Back up? |
|---|---|---|
database_data |
MariaDB — messages, users, everything relational | Yes — critical |
media |
Uploaded files: avatars, attachments, emoji images | Yes — critical |
secrets |
Generated app secret, JWT passphrase, Mercure/media signing keys, VAPID keypair, database + Meilisearch + LiveKit credentials | Yes — critical |
exports |
Pending GDPR data-export downloads | Recommended (small, transient) |
jwt |
Signed JWT keypair | Recommended (restoring it avoids invalidating every session) |
meili_data |
Search index | Skip — rebuild with tyto:search:reindex |
redis_data |
Cache, rate limits, presence | Skip — the container itself disables persistence (--save "" --appendonly no); nothing durable is ever stored here |
Running the single-stack bundle? Its extra app_web volume only holds the client’s static files, rebuilt from the image on every start by the one-shot web-files service — it’s never user data and never needs backing up.
The client stack (a separate docker compose project when not bundled) has
no volumes with application data — its caddy_data/caddy_config volumes
only cache the auto-HTTPS certificate, which Caddy re-issues on its own if
lost.
Keep your .env file under backup too — it isn’t in a volume, and without
it you don’t know TYTO_VERSION, SERVER_DOMAIN, or any override you set.
Back up
A consistent snapshot needs a locked-in-time database dump plus the volumes
above, all from around the same moment. From the tyto-core directory:
mkdir -p backup
docker compose exec database sh -c \
'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" tyto' > backup/database.sql
docker compose exec -T app tar czf - -C /secrets . > backup/secrets.tar.gz
docker compose exec -T app tar czf - -C /app/var/media . > backup/media.tar.gz
docker compose exec -T app tar czf - -C /app/var/exports . > backup/exports.tar.gz
docker compose exec -T app tar czf - -C /app/config/jwt . > backup/jwt.tar.gz
cp .env backup/.env
The dump command works because the database service resolves its root
password from MARIADB_ROOT_PASSWORD_FILE into the plain environment
variable at container start — no separate secret-reading step needed. The
volume copies stream through the already-running app container, which has
all four mounted at the paths above, so there’s no need to know Docker’s
internal volume names.
Automate it
A daily cron entry covers most deployments. Keep a rotation window so backups don’t grow without bound:
0 3 * * * root cd /opt/tyto-core && ./backup.sh --dated && find /opt/tyto-core/backup -maxdepth 1 -mtime +14 -exec rm -rf {} +
Wire --dated into backup.sh to write into a timestamped subdirectory
(e.g. backup/2026-07-20/) instead of overwriting in place, so the find -mtime prune above has multiple generations to fall back on. Ship the
resulting archives off-host (object storage, another machine) — a backup
that lives on the same disk as the server survives a bad deploy but not a
lost disk.
A backup you haven’t restored is a guess, not a backup. Periodically run the restore procedure below against a scratch host and confirm the app actually comes up and logs in — that’s the only way to know the dump, the volumes and the automation script actually agree with each other.
Restore
On a fresh host, install the stack per the quickstart but stop before the last step:
- Clone and configure
git clone https://github.com/tyto-chat/core tyto-core && cd tyto-core, restore your saved.env(or recreate it with the sameTYTO_VERSIONand domain values) — do not rundocker compose up -dyet. - Provision the volumes without starting anything
docker compose createcreates every container (and the named volumes they reference) without running the entrypoint, so nothing has generated secrets or initialized a database yet. - Seed the volumes from your archives
For each of
secrets,media,exports,jwt:docker run –rm -v tyto-core_<volume>:/data -v $(pwd)/backup:/backup:ro alpine sh -c “cd /data && tar xzf /backup/<volume>.tar.gz”. Confirm the exact volume names first withdocker volume ls | grep tyto-coreif you changed the project name. - Start the stack
docker compose up -d.secrets-initand the database now boot against the secrets and (if you restored it as a volume rather than a dump) the datadir you seeded, not fresh ones. - Import the SQL dump
Skip this if you restored
database_dataas a volume archive in step 3. Otherwise, oncedatabaseis healthy:cat backup/database.sql | docker compose exec -T database sh -c ‘mariadb -u root -p“$MARIADB_ROOT_PASSWORD“ tyto’. - Rebuild the search index
docker compose exec app php bin/console tyto:search:reindex— idempotent, safe even ifmeili_datacame back stale or empty.
The secrets volume must be restored before the first boot on the new host, not after. docker/gen-secrets.sh only ever writes a secret if it’s not already present — restore it first and every container reuses your original APP_SECRET, JWT_PASSPHRASE and database password. Boot without it and secrets-init generates a brand-new set instead: the freshly generated MARIADB_PASSWORD won’t match the user baked into a restored database_data datadir (the _FILE env vars are only consumed on first datadir init), and a new JWT_PASSPHRASE can’t decrypt a restored jwt keypair encrypted under the old one — so the app can’t reach its own database, can’t sign or verify tokens, and every previously issued JWT plus every signed media URL stops working. Restoring the two volumes together, before anything starts, is what keeps them consistent.
Migrating to a new server
Migrating is the same restore procedure aimed at a new host instead of a
recovered one: back up the current server, run the steps above there, then
cut DNS for SERVER_DOMAIN (and APP_DOMAIN if you run the
single-stack bundle) over to it.
Give the old records time to expire out of caches before decommissioning the
source host. Whether the source or destination runs the bundle or a split
client/core stack doesn’t change any of this — the bundle only adds the
client’s static-file volume, which carries no data to move.