Backup and restore
cmd/gramsrv-backup captures a gramsrv deployment and puts it back on another
host: the PostgreSQL databases, the media and key tree, and the configuration
needed to bring the same instance up again.
Use it for host migrations, for moving between the monolith and the Compose deployment, and for scheduled snapshots you can restore from if a disk dies.
- What a bundle contains
- Capturing a bundle
- Verifying a bundle
- Restoring onto a new host
- Checklist after a restore
- Updating before you migrate
- Why the dumps look the way they do
- Troubleshooting
What a bundle contains
A bundle is a directory. Copy it with rsync, scp or a zip; nothing in it
depends on the host it came from.
<bundle>/
MANIFEST.json what was captured, from which commit, with which row counts
SHA256SUMS sha256sum(1) compatible digests for every artifact
server/
schema.sql.zst plain SQL dump of the main database
globals.sql.zst roles and their password hashes
redis.rdb.zst Redis snapshot, when captured
data.tar.zst the data/ tree: media, server RSA key, login signing keys
config.tar.zst .env, systemd units, Compose files
grammystore/ the same layout for the store bot, when selected
MANIFEST.json records the git commit, the schema version and the exact row
counts of the tables that matter for a migration. Compare those numbers after a
restore to prove the copy is complete.
A bundle contains production secrets. It includes .env, the server RSA key
and the Telegram Login signing keys. Keep it out of version control, transfer it
over a channel you trust, and delete it once the new host is verified.
Two things are deliberately not in a bundle:
- Compiled binaries. Rebuild from the commit in the manifest.
- Reverse proxy configuration. TLS and routing live outside gramsrv.
Capturing a bundle
The scripts pick sensible defaults and build the tool if needed:
./scripts/backup.sh --component server
For a capture that is consistent down to the media files, stop the writers. They are always restarted, including when the capture fails:
./scripts/backup.sh --component all \
--pg-container-server telesrv-postgres \
--pg-container-grammystore grammystore_postgres_dev \
--freeze-units gramsrv.service,gramsrv-admin.service \
--freeze-containers grammystore_app_dev
Or call the tool directly:
go build -o bin/gramsrv-backup ./cmd/gramsrv-backup
./bin/gramsrv-backup backup --out /srv/backups/20261004 --component all \
--include-data --pg-tools docker --pg-container-server telesrv-postgres
Notes that save time on a real host:
- A database dump needs no downtime.
pg_dumptakes a consistent snapshot while the server keeps running. Freezing only matters fordata.tar.zst: without it a tar can catch an upload mid-file and store a truncated object. --pg-tools dockeruses thepg_dumpinside your database container, which is always the matching major version. Use--pg-tools localwhen the host has client tools installed; add--pg-bin-dirif they are not onPATH.- Each component has its own database, so pass
--pg-container-serverand--pg-container-grammystorewhen they run in different containers. --skip-redis-style choices exist:-include-redis=falseomits the Redis snapshot. Redis holds rate limits and delivery bookkeeping, so a fresh Redis is usually acceptable.- Budget roughly the size of
data/plus a compressed database. A small instance produces tens of megabytes; an instance with a large media store produces gigabytes.
Verifying a bundle
Always verify before you rely on a bundle, and again after transferring it:
./bin/gramsrv-backup verify --from /srv/backups/20261004
This re-hashes every artifact, confirms each one still decompresses, and prints the recorded row counts.
To check the media tree of a running instance against its database, which catches a truncated copy:
./scripts/check-media.sh --pg-container telesrv-postgres
media: referenced=9794 on_disk=9940 bytes=1126781238 missing=0 orphan=146 corrupt=0
missing and corrupt must both be zero. orphan counts files no row
references, which is harmless leftover weight.
Restoring onto a new host
The bundle restores databases, files and configuration. It does not install Docker, start containers, or move DNS. Plan for that part yourself.
-
Prepare the host. Install Docker with Compose, and make sure the ports the deployment needs are reachable: the MTProto listener, the public link web endpoint, the admin API and UI, RTMP ingest, and the media ports (SFU UDP and the TURN UDP port plus its relay range). Take the exact values from your
.env; the defaults are documented indocs/configuration.en.md. -
Put the matching project version in place. Use the release package that corresponds to the commit recorded in
MANIFEST.jsonand place it in the deployment directory. -
Start the databases. Bring up Postgres and Redis with your Compose files before restoring, otherwise there is nothing to restore into. Use the same passwords as the source host: they come back with
globals.sql.zst. -
Restore the bundle:
./scripts/restore.sh --from /srv/backups/20261004 --dry-run # read the plan first ./scripts/restore.sh --from /srv/backups/20261004 --force--forceis required to overwrite files that already exist. The schema restore runs in a single transaction, so a failure leaves the database as it was rather than half migrated. -
Adjust the host-specific settings in the restored
.env:TELESRV_ADVERTISE_IPmust be the new address. This is the one setting that is always required: the TURN and SFU advertise addresses fall back to it, so a wrong value breaks calls even though text messaging works.TELESRV_PUBLIC_BASE_URLand the related public URL settings if your domain changes.- Any password or token you deliberately rotated.
-
Start gramsrv and the admin panel, then run the checklist below.
Checklist after a restore
Work through this in order; each step catches a different class of mistake.
-
/healthzon the public link web endpoint returnsok. - The admin panel loads and the operator list is intact.
- Open Telegram Desktop with an account that was already logged in on the old host. Staying signed in proves the RSA key and the auth keys were restored. Being asked for a code again means the key is not the one the client pinned.
- Download a photo and a video that were uploaded before the migration.
- Place a voice or video call. It exercises the advertised media addresses.
- The store bot answers, if you run one.
-
check-media.shreportsmissing=0 corrupt=0. - Row counts in
MANIFEST.jsonmatch what the restored database reports. - Only then move DNS, and keep the old host reachable until the new one has been up long enough to be confident.
Updating before you migrate
Migrate the code and the data together. If you update gramsrv first and back up afterwards, the bundle matches the version you are deploying, which is what you want.
git fetch origin && git merge --ff-only origin/main
gofmt -l ./cmd ./internal # expect no output
go vet ./...
go test ./... -count=1
Then reconcile configuration before restarting, because a new release can add settings:
diff <(grep -oE '^[A-Z_]+=' .env.example | sort -u) \
<(grep -oE '^[A-Z_]+=' .env | sort -u)
Anything listed by .env.example and missing from your .env needs a value.
Keys your deployment carries that upstream dropped are ignored and can stay.
Build and swap binaries in one restart, and keep the previous build so you can roll back:
cp bin/gramsrv bin/gramsrv.previous
go build -o bin/gramsrv ./cmd/telesrv
go build -o bin/gramsrv-admin ./cmd/telesrv-admin
systemctl restart gramsrv gramsrv-admin
journalctl -u gramsrv -f | grep 'server ready'
Start the server only through its service manager. Running the binary by hand binds the same ports as the running instance and applies pending migrations, which is not what you want during an update.
Why the dumps look the way they do
The artifacts are tuned for one goal: replaying them on a different host, possibly on a different PostgreSQL major version, with the least surprise.
- Plain SQL, not the custom format. Any
psqlcan replay plain SQL, and it does not have to match thepg_dumpthat produced it. The custom format ties a dump to a client version. --clean --if-exists. The dump can be replayed over a populated database, which makes re-restoring a single component possible.--no-owner --no-privileges. Ownership comes from the role that replays the dump, so a differently named role on the target is not a problem.- No
--create. The database name is never baked into the artifact, so the same dump restores into a scratch database for testing. - Restored with
ON_ERROR_STOP=1 --single-transaction. Either the whole schema lands or nothing does. - Two constructs are stripped on the way in.
\restrictand\unrestrictare psql meta-commands added in PostgreSQL 17.5 as a mitigation for CVE-2025-8714; older clients reject them.SET transaction_timeoutonly exists on PostgreSQL 17 and newer. Removing them makes a dump from a newer server replayable on an older one. The cost is that the\restrictguard against malicious schema objects during restore is gone, so restore into a host you control. - Passwords are passed through the environment, never on a command line, so
they do not appear in
psoutput. - File modes and timestamps are preserved, including
0600on the key files and0700on the directory holding the login signing keys. Setuid, setgid and sticky bits are dropped on restore.
A dump from PostgreSQL N restores into N or newer only. The tool checks the target version before it starts and refuses to restore into an older server rather than leaving a half-applied schema.
Troubleshooting
relation "..." does not exist while restoring. The dump belongs to a
different database, or --dbname pointed somewhere else. Check the database name
in MANIFEST.json against your DSN.
role "..." already exists while restoring globals. Expected on a host that
already has the role. Restore with -restore-globals=false when the target roles
are already correct.
Restore fails with unrecognized configuration parameter. The dump came from
a newer major version than the target. Restore into the same version or newer.
missing > 0 in the media check. The database references objects that are not
on disk. Restore data.tar.zst again, and if it persists, the source host was
already missing those objects.
corrupt > 0. A stored object's content does not match its name, which means
a truncated or corrupted copy. Copy the bundle again and re-verify.
The server starts but clients ask for a login code again. The RSA key at
data/server_rsa.pem is not the one the clients pinned. Restore the original key;
a new key means every client has to re-scan the configuration.
Nothing listens on the MTProto port. TELESRV_ADVERTISE_IP is the value
clients are told to connect to, and it must match the address the new host is
reachable on. Check it first.