I've now got a solid picture of both the ingress mix-in mechanism and the volume internals. Here's my assessment.
Short version
The scheme is sound, and the ingress-style "mix-in" is a genuinely good fit — for the Docker target. For Kubernetes the same idea runs into a hard wall (ReadWriteOnce PVCs) and needs a different mechanism. The S3/encryption part is the easy part — solved by off-the-shelf tools. The hard parts are (1) concurrent access to other services' volumes and (2) application-level consistency. Your restore design actually sidesteps the worst of #1, which is the cleverest part of your proposal.
Tooling: don't write the S3 logic yourself
Use restic (or kopia) as the engine. Both are single-binary Go tools that do exactly what you want and treat object storage as hostile by design:
- Client-side encryption is mandatory and built-in (restic: AES-256 + Poly1305). The payload is encrypted before upload, so the S3 bucket never sees plaintext — precisely your requirement.
- Content-addressed dedup + incremental snapshots, so a daily backup of a mostly-static volume costs almost nothing.
- Native S3 backend (plus B2, GCS, Azure, any S3-compatible like MinIO/Wasabi).
- Snapshots + retention/forget policies (
keep-daily 7 keep-weekly 4 …) and easy point-in-time restore — your "previous epoch."
restic is the more battle-tested/simpler; kopia adds a built-in scheduler and policy engine. I'd default to restic for a first cut.
For the container itself, you very likely don't need to build much: offen/docker-volume-backup is purpose-built for exactly this use case — a container that mounts Docker volumes, backs them up on a cron schedule to S3/restic/WebDAV with encryption, and supports pre/post hooks for quiescing. It would let your new repo be mostly a thin stack wrapper rather than original code. Worth evaluating before committing to a from-scratch image. (mazzolino/restic is a simpler restic-on-cron alternative.)
Docker target: your scheme works almost verbatim
The ingress precedent confirms the plumbing: mixed-in specs merge into a single Compose project sharing one network (the ps output in ingress.md shows all containers under one stack-<hash> project, and spec.py:298-302 already reconciles volume-name conflicts across merged specs). So a backup service dropped in as another spec lands in the same project.
The one difference from ingress: nginx-proxy only needs the Docker socket to discover env vars. Your backup container needs to actually read the bytes, which means each named volume must be mounted into the backup container (read-only). That's the auto-generated config you anticipated — the stack tool already enumerates volumes via Spec.get_volumes(), so generating - <vol>:/backup/<vol>:ro for every volume in the merged deployment is straightforward. (volumes_from: is an alternative but per-container; explicit read-only mounts of named volumes are cleaner and let you back up volumes regardless of which service owns them.)
Kubernetes target: this is where the design needs rethinking
cluster_info.py:233,303 creates every PVC as ReadWriteOnce. That single fact breaks the "backup as a separate mixed-in pod" model: an RWO volume already attached to the app pod cannot be mounted into a second pod (especially across nodes). So on K8s you can't have a standalone backup pod read live volumes the way Docker can. Options, roughly in order of cleanliness:
- True sidecar — inject the backup container into each pod that has volumes, sharing that pod's mounts. Works, but it's invasive (mutates every pod, not a clean drop-in) and couples backup to app lifecycle.
- CronJob that mounts the PVCs — only valid if volumes are RWX, or if the app is quiesced/scaled-to-zero during the backup window.
- Switch backup-eligible volumes to
ReadWriteMany— requires an RWX-capable storage class (NFS, CephFS, etc.), which many clusters don't have.
I'd flag this asymmetry explicitly in the design doc: Docker gets the clean mix-in; K8s backup-while-running needs a sidecar or an RWX story. It's fine to ship Docker-first.
Restore: your "backup-only stack" idea is the strongest part
Starting a stripped deployment with only the backup service, restoring into the (freshly-created, empty) volumes, then stopping it and starting the full stack — this is elegant precisely because at restore time nothing else holds the volumes. That eliminates the concurrent-mount problem on both targets: on Docker compose up with only the backup service still creates all declared named volumes empty; on K8s a one-shot Job can mount the RWO PVCs because no app pod is attached yet. So restore works uniformly even though backup doesn't. I'd lean into this and make restore a first-class stack manage … restore --snapshot <id> mode that deploys exactly this minimal variant.
The thing most likely to bite you: consistency
The ingress analogy is reassuring but slightly misleading — ingress is stateless; backup is deeply stateful. File-level copy of a live volume under a running database (Postgres, etc.) can produce a torn, unrestorable snapshot. You need a quiesce mechanism:
- pre-hook to flush/lock or dump (
pg_dump,fsfreeze, or briefly stopping the writer), post-hook to release. - This maps beautifully onto your existing annotation pattern. Just as
# @stack http-proxy /apidrives ingress config, you could have# @stack backup-volume /dataand# @stack backup-pre-hook pg_dump …annotations parsed atinitinto abackup:spec section. That keeps the per-service knowledge with the service and reuses machinery you already have.
Other things to design in early
- Secrets: the restic password and S3 credentials must live in your config/profile system (and as a K8s Secret), never in the spec under git. This is a prerequisite, not an afterthought.
- Retention/prune policy as config; monitoring (did last night's backup succeed?) via a healthcheck or exit-status surface — silent backup failure is the classic disaster.
- Restore granularity: expose snapshot listing so the operator picks the epoch.
- New repo: yes, mirror
docker-ingress-stack's layout (stacks/<name>/stack.yml+<name>/composefile.yml) — e.g.bozemanpass/backup-stack.
My recommendation
Build it Docker-first as a backup-stack repo wrapping restic (evaluate offen/docker-volume-backup to avoid writing the engine), auto-generate read-only volume mounts and the backup target list from Spec.get_volumes(), drive per-service quiesce via @stack backup-* annotations, and implement restore as a dedicated minimal-deployment mode. Treat K8s backup-while-running as a separate, later phase (sidecar or RWX), but note that restore can work on K8s from day one via a Job, thanks to your start-stripped-then-restore design.
Want me to (a) write this up as a design doc in docs/, (b) prototype the spec-generation changes (get_volumes() → backup container mounts) in the stack tool, or (c) scaffold the new backup-stack repo layout? I'd suggest starting with (a) so we pin down the annotation schema and the Docker-vs-K8s scope before any code.
Note: I had a background agent mapping the volume-merge internals in more detail — I can fold its findings in if you want to go deeper on the spec-merge mechanics before deciding.