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
Note

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.

Tip

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:

  1. 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 same TYTO_VERSION and domain values) — do not run docker compose up -d yet.

  2. Provision the volumes without starting anything

    docker compose create creates every container (and the named volumes they reference) without running the entrypoint, so nothing has generated secrets or initialized a database yet.

  3. 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 with docker volume ls | grep tyto-core if you changed the project name.

  4. Start the stack

    docker compose up -d. secrets-init and 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.

  5. Import the SQL dump

    Skip this if you restored database_data as a volume archive in step 3. Otherwise, once database is healthy: cat backup/database.sql | docker compose exec -T database sh -c ‘mariadb -u root -p“$MARIADB_ROOT_PASSWORD“ tyto’.

  6. Rebuild the search index

    docker compose exec app php bin/console tyto:search:reindex — idempotent, safe even if meili_data came back stale or empty.

Danger

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.