Volume backups with restic
Nightly restic snapshots to any S3-compatible backend plus postgres WAL archiving for point-in-time recovery.
Nightly off-host backups of your docker volumes via restic, running as a yoink-managed service. Repo lives on any S3-compatible object storage: Hetzner Object Storage, Backblaze B2, AWS S3, MinIO, Wasabi, Vultr, or a self-hosted RustFS on a tailnet-reachable off-site host. You bring the bucket + S3 keys; yoink wires the rest via templates.
TL;DR
Provision a bucket and S3 keys at your storage provider
One-time, outside yoink. Any S3-compatible backend works; for a self-hosted RustFS path see Self-hosted backend below.
Render the backup service from the bundled template
yoink add restic-backups \
--var "repository=s3:https://s3.example.com/MY-BUCKET" \
--var "schedule=0 30 3 * * *" \
--var "retention=--keep-daily 7 --keep-weekly 4 --keep-monthly 6"Seal the operator-supplied S3 keys
RESTIC_PASSWORD was auto-generated by the template; only the S3 keys are operator-supplied.
yoink secrets edit
# S3_ACCESS_KEY_ID=… [step]
# S3_SECRET_ACCESS_KEY=… [step]List which volumes to back up, then deploy
Edit services/backups.yaml to uncomment + repoint the example bind mounts, then:
yoink upAfter the next cron tick (default 0 30 3 * * * = 03:30 UTC), restic snapshots against the bucket lists your first backup.
What this gives you
- A
backupsservice runningmazzolino/restic: restic + go-cron in one container, nobuild:block needed. - Source docker volumes mounted read-only into the container; restic can read but never modify them.
- Repo encrypted with
RESTIC_PASSWORD; access gated by S3 keys; both live in sealed secrets, never in cleartext on disk. - Sensible retention:
--keep-daily 7 --keep-weekly 4 --keep-monthly 6.
A note on dedup
restic uses content-defined chunking: files are split into variable-size chunks at content boundaries, each chunk is hashed (SHA-256), encrypted, and stored by content address. A re-backup only ships chunks the repo doesn't already have. Edit one row in a 2 GB postgres dump → maybe 50 KB of new chunks cross the wire. Daily backups of slow-changing data become near-free in bandwidth and storage. This is what makes the "ship to your own off-site host over a slow link" path below actually viable.
Self-hosted backend (RustFS over Tailscale)
If you want full sovereignty (no third-party storage provider, no egress charges, no vendor lock-in), deploy RustFS (Apache-2.0 S3-compatible object storage) on a separate tailnet-reachable host and point the backups service at it. The rustfs template handles the deploy:
Deploy RustFS on the off-site host
The host must already be in your hosts: list and reachable over the tailnet.
yoink add rustfs --var "bind_address=100.64.1.5" # the host's tailnet IPEdit services/rustfs.yaml to pin hosts: [vault-host.tailnet] so it only deploys on the off-site box, then yoink up.
Pre-create the data volume with the right ownership
RustFS runs as uid 10001; chown its data volume so the daemon can write to it before the first deploy.
ssh root@<vault-host> '
docker volume create rustfs-rustfs-data
chown -R 10001:10001 /var/lib/docker/volumes/rustfs-rustfs-data/_data
'Create the backup bucket
restic doesn't auto-create S3 buckets, only the repo metadata inside one. After RustFS is up, use any S3 client against the running RustFS:
ssh root@<vault-host> "docker run --rm --network=<your-network> \
-e AWS_ACCESS_KEY_ID='$(yoink secrets show RUSTFS_ACCESS_KEY --reveal | cut -d= -f2-)' \
-e AWS_SECRET_ACCESS_KEY='$(yoink secrets show RUSTFS_SECRET_KEY --reveal | cut -d= -f2-)' \
-e AWS_REGION=us-east-1 \
amazon/aws-cli --endpoint-url=http://rustfs:9000 s3 mb s3://backups"Render the backups service against the RustFS endpoint
yoink add restic-backups \
--var "repository=s3:http://rustfs-vault.tailnet:9000/backups"Reuse RustFS credentials as the S3 keys
The auto-generated RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY are the S3 root credentials for that RustFS instance. Copy them into the S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY slots that restic-backups expects:
yoink secrets edit
# S3_ACCESS_KEY_ID <- paste from `yoink secrets show RUSTFS_ACCESS_KEY --reveal` [step]
# S3_SECRET_ACCESS_KEY <- paste from `yoink secrets show RUSTFS_SECRET_KEY --reveal` [step]Deploy and watch dedup do its thing
yoink up deploys the backups service. Watch the second backup land; only changed chunks ship. See pairing with Tailscale for tailnet hostname conventions.
Bonus: serve a bucket as public HTTPS via yoink's Caddy
RustFS is S3-compatible, so a bucket policy can flip a single bucket to anonymous-read and yoink's bundled Caddy can front it as a public asset endpoint.
Make a single bucket world-readable
Object-level access only, not bucket-listing. Run from any host on the same docker network.
POLICY='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::public-assets/*"}]}'
docker run --rm --network=<your-network> \
-e AWS_ACCESS_KEY_ID="$(yoink secrets show RUSTFS_ACCESS_KEY --reveal | cut -d= -f2-)" \
-e AWS_SECRET_ACCESS_KEY="$(yoink secrets show RUSTFS_SECRET_KEY --reveal | cut -d= -f2-)" \
-e AWS_REGION=us-east-1 \
amazon/aws-cli --endpoint-url=http://rustfs:9000 s3api put-bucket-policy \
--bucket public-assets --policy "$POLICY"Add a domain: and Caddy rewrite to services/rustfs.yaml
The rewrite injects the bucket prefix so /foo.png at the public domain maps to /public-assets/foo.png against RustFS; yoink's auto-generated reverse_proxy rustfs:9000 (emitted because domain: is set) handles the actual proxying with the rewritten path.
domain: assets.example.com
caddy_extra_caddyfile: |
rewrite * /public-assets{path}Deploy and hit the public domain
After yoink up, https://assets.example.com/foo.png serves the object from the public-assets bucket. TLS is via Let's Encrypt by default (or sealed origin certs; see Cloudflare origin certs). The path-rewrite scopes the public endpoint to one bucket so the rest of your S3 endpoint (other buckets, the admin API) stays internal-only. Use this only for genuinely public assets, since the bucket policy is the sole access control on objects under public-assets/*. Add a method matcher (@get method GET HEAD + respond 405 for everything else) if you want defense-in-depth against rogue PUT attempts.
PostgreSQL: point-in-time recovery via WAL archiving
A nightly restic snapshot of pgdata-* is fine for "yesterday's data", but a crash at 14:30 with the last snapshot at 03:30 loses 11 hours. PostgreSQL's continuous WAL archiving closes that gap: every committed transaction's write-ahead log segment ships to S3 as it's filled, and restore_command replays them on top of any base backup. The postgres template renders this wiring when wal_archiving=true:
Provision a WAL bucket
The bucket must exist before postgres starts archiving; restic-style auto-init does not apply to archive_command.
Seal the S3 keys (or reuse the ones from restic-backups)
WAL upload is just aws s3 cp, so the same keys cover both flows.
yoink secrets edit
# S3_ACCESS_KEY_ID=… [step]
# S3_SECRET_ACCESS_KEY=… [step]Render postgres with WAL archiving on
yoink add postgres \
--var wal_archiving=true \
--var wal_repository=s3://wal-archive \
--var wal_endpoint=http://rustfs:9000 # omit for AWS S3Pair with restic-backups against the data volume
Bind the postgres data volume read-only into the backups service so the base snapshot half of PITR is in place too.
# services/backups.yaml [step]
- pgdata-postgres:/data/pgdata-postgres:roThis emits a service that wraps the stock postgres entrypoint with a one-line apk add aws-cli and starts postgres with archive_mode=on, wal_compression=zstd (full-page images compressed inside WAL records), and an archive_command that pipes each segment through gzip -c before upload, so the on-disk WAL is denser before it ships and the segment files in S3 are gzip-compressed .gz blobs (idle-database segments compress from 16 MiB to a few KiB; busy ones to ~3-6 MiB). archive_timeout=300 forces a flush every 5 min so an idle database still ships its tail.
Restoring to a point in time
Manual procedure; out of scope for the deploy reconcile loop.
Restore the latest pre-incident base snapshot
restic restore the latest pre-incident snapshot of pgdata-postgres to a fresh volume.
Wire restore_command to the WAL archive
Add a recovery.signal file plus restore_command to the recovered data dir. The .gz suffix and gunzip mirror the archive-side compression:
restore_command = 'aws s3 cp s3://wal-archive/%f.gz - | gunzip -c > %p'Set the recovery target and start postgres
Set recovery_target_time to the desired moment, start postgres, wait for replay to complete.
Full PITR mechanics live in the PostgreSQL continuous archiving docs. The yoink piece is just the upload half; archive_command is the only contract postgres has with the storage backend.
WAL archive retention is currently a manual concern: aws s3 rm (or RustFS lifecycle rules) older than your restic retention window. A future iteration of this recipe may bundle pg_archivecleanup as a sidecar; for now keep WAL retention ≥ your oldest restic snapshot.
Verify
Tail go-cron's schedule log
yoink up
yoink logs backups -fYou should see go-cron logging the next scheduled run. Force a snapshot immediately by uncommenting RUN_ON_STARTUP: "true" in services/backups.yaml, redeploying, then flipping it back off once you've confirmed.
List snapshots from your laptop
With the same secrets in env, list snapshots against the same repo URL:
docker run --rm \
-e RESTIC_REPOSITORY="s3:https://s3.example.com/MY-BUCKET" \
-e RESTIC_PASSWORD="$(yoink secrets show RESTIC_PASSWORD --reveal)" \
-e AWS_ACCESS_KEY_ID="$(yoink secrets show S3_ACCESS_KEY_ID --reveal)" \
-e AWS_SECRET_ACCESS_KEY="$(yoink secrets show S3_SECRET_ACCESS_KEY --reveal)" \
mazzolino/restic:1.8.2 \
restic snapshotsRestore
restic restore and restic mount (FUSE browsing) run as one-off docker run invocations against the same mazzolino/restic image; pass the same env vars as the verify step above, plus --device /dev/fuse --cap-add SYS_ADMIN for the mount case. Full restore syntax lives in the restic docs; a long-lived FUSE service in a deploy reconcile loop is the wrong shape, but if you genuinely want one, hardware passthrough covers the devices: + cap_add: config.
Troubleshooting
Fatal: unable to open repository at s3:…
The repo doesn't exist yet. The first restic backup initializes it automatically; wait for the first cron tick or set RUN_ON_STARTUP=true. If it still fails, the RESTIC_REPOSITORY URL is wrong or the S3 keys don't have read+write on the bucket.
restic panics with signal: terminated during a redeploy.
drain_timeout (60s by default) wasn't enough for an in-flight backup to abort cleanly. Bump drain_timeout in the rendered service, or schedule deploys outside the backup window.
See also
yoink addtemplates — how the bundled templates work and how to author your own.- Sealed secrets —
yoink secrets editflow andsecrets.agemechanics. - Hardware passthrough —
devices:andcap_add:if you want FUSE / GPU / USB inside a yoink-managed container. - Pairing with Tailscale — tailnet hostname conventions for the self-hosted RustFS path.
- restic docs — backend semantics, retention policies, restore patterns. We don't re-explain restic here.
- resticker — the upstream image that bundles restic + go-cron, plus its full env-var surface (
PRE_COMMANDSfor db dumps,POST_COMMANDS_*for notifications). - RustFS — the upstream object storage daemon used by the
rustfstemplate.