Yes please go ahead. Perhaps also think about how backup would be integrated into the stack command structure? E.g. would we have a "stack deploy backup status" command, and so on?
Let me study the existing command structure so the CLI proposal fits the grain rather than inventing something foreign.
grep -n "add_command\|@click.group\|@click.command\|def cli\|cli.add\|\.add_command\|@.*\.command\|@.*\.group" src/stack/main.py | head -60
45:@click.group(context_settings=CONTEXT_SETTINGS, cls=StackCLI) 56:def cli(ctx, profile, quiet, verbose, log_file, dry_run, debug): 82:cli.add_command(build.command, "build") 83:cli.add_command(chart.command, "chart") 84:cli.add_command(checklist.command, "check") 85:cli.add_command(complete.command, "complete") 86:cli.add_command(config.command, "config") 87:cli.add_command(deployment_create.create, "deploy") 88:cli.add_command(fetch.command, "fetch") 89:cli.add_command(init.command, "init") 90:cli.add_command(list_stack.command, "list") 91:cli.add_command(deployment.command, "manage") 92:cli.add_command(prepare.command, "prepare") 93:cli.add_command(update.command, "update") 94:cli.add_command(version.command, "version") 95:cli.add_command(webapp.command, "webapp")
ls src/stack/deploy/*.py; echo "=== manage command ==="; grep -rn "manage\|def command\|@click" src/stack/deploy/manage*.py src/stack/manage*.py 2>/dev/null | head -40
src/stack/deploy/__init__.py src/stack/deploy/deploy.py src/stack/deploy/deploy_types.py src/stack/deploy/deploy_util.py src/stack/deploy/deployer.py src/stack/deploy/deployer_factory.py src/stack/deploy/deployment.py src/stack/deploy/deployment_context.py src/stack/deploy/deployment_create.py src/stack/deploy/explain.py src/stack/deploy/images.py src/stack/deploy/spec.py src/stack/deploy/stack.py === manage command ===
grep -n "@click\|def command\|add_command\|@command\|\.command()\|group\|ctx.invoke\|start\|stop\|\"ps\"\|logs\|exec" src/stack/deploy/deployment.py | head -60
31: exec_operation,
32: logs_operation,
44:@click.group()
45:@click.option("--dir", required=True, help="path to deployment directory")
46:@click.pass_context
47:def command(ctx, dir):
48: """manage a deployed stack (start, stop, etc.)"""
83:@command.command()
84:@click.pass_context
91:@command.command()
92:@click.option(
97:@click.option(
102:@click.argument("extra_args", nargs=-1) # help: command: start <service1> <service2>
103:@click.pass_context
104:def start(ctx, stay_attached, skip_cluster_management, extra_args):
105: """start the deployment"""
111:@command.command()
112:@click.option("--delete-volumes/--preserve-volumes", default=False, help="delete data volumes")
113:@click.option(
118:@click.argument("extra_args", nargs=-1) # help: command: down <service1> <service2>
119:@click.pass_context
120:def stop(ctx, delete_volumes, skip_cluster_management, extra_args):
121: """stop the deployment and remove the containers"""
127:@command.command()
128:@click.pass_context
135:@command.command()
136:@click.pass_context
144:@command.command()
145:@click.argument("extra_args", nargs=-1) # help: command: port <service1> <service2>
146:@click.pass_context
153:@command.command()
154:@click.argument("extra_args", nargs=-1) # help: command: exec <service> <command>
155:@click.pass_context
156:def exec(ctx, extra_args):
157: """execute a command inside a container"""
159: exec_operation(ctx, extra_args)
162:@command.command()
163:@click.option("--tail", "-n", default=None, help="number of lines to display")
164:@click.option("--follow", "-f", is_flag=True, default=False, help="follow log output")
165:@click.argument("extra_args", nargs=-1) # help: command: logs <service1> <service2>
166:@click.pass_context
167:def logs(ctx, tail, follow, extra_args):
168: """get logs for running containers"""
170: logs_operation(ctx, tail, follow, extra_args)
173:@command.command()
174:@click.pass_context
181:@command.command()
182:@click.pass_context
189:@command.command()
190:@click.pass_contextThe structure is clear now:
stack deploy = create a deployment (one-shot, from specs).stack manage --dir <dir> <verb> = operate on an existing deployment β a Click group with start/stop/ps/logs/exec/port subcommands (deployment.py:44).So backup operations belong under stack manage β¦ backup <verb>, not under deploy (which only creates). I'll fold that reasoning into the doc. Writing it now.
# Backup & Restore
> **Status: design proposal β not yet implemented.** This document describes the intended design for
> backing up and restoring service data. It is written to be reviewed and refined before any code is
> written. Where it describes commands or behaviour, read those as *proposed*.
Stacks keep their software components and configuration under revision control in git. Once a stack is
running, however, it accumulates **persistent data** in mounted volumes that git does not track. This
document describes how that data is backed up to object storage (S3) and restored from a previous epoch.
The approach mirrors the [ingress](./ingress.md) design: a small amount of generated configuration wires
a backup capability into any stack, transparently across both deployment targets (Docker Compose and
Kubernetes), while leaning on a mature, off-the-shelf engine to do the actual work.
## Engine: restic
All backups β on both targets β are stored as a [restic](https://restic.net) repository. restic is the
contract, not an implementation detail:
- **Client-side encryption is mandatory** (AES-256). The payload is encrypted *before* upload, so the
object store never sees plaintext. This is what makes backing up to commodity object storage acceptable.
- **Content-addressed dedup + incremental snapshots.** A daily backup of a mostly-static volume costs
almost nothing.
- **Snapshots and retention policies** give point-in-time restore (your "previous epoch").
- **Native S3 backend** (and any S3-compatible store: MinIO, Wasabi, etc.).
Standardising on the restic repository format means a backup written on the Docker target is restorable on
the Kubernetes target and vice-versa, and that in a pinch an operator can restore with the bare `restic`
CLI from outside the deployment entirely.
The two targets differ only in *what runs restic and how it is scheduled*:
| Concern | Docker | Kubernetes |
| ------------------ | ------------------------------------------------- | -------------------------------------------- |
| Engine / format | restic (off-the-shelf restic container image) | restic (via **K8up**) |
| Scheduling | cron in the backup container | K8up `Schedule` resource |
| Quiesce / hooks | pre/post hooks in the backup container | `k8up.io/backupcommand` pod annotation |
| Config generation | `stack` injects config at deploy time | `stack` emits K8up resources |
| Prerequisites | the mixed-in `backup` stack | the `cluster` tool ensures K8up is present |
| Restore | start-stripped β restic restore β start full | K8up `Restore` into freshly-created PVCs |
## Annotations
Which data gets backed up β and how it is made consistent β is described with comment-based annotations in
`composefile.yml`, exactly as HTTP routes are for ingress.
By default, **all read-write named volumes** in a deployment are backed up (file-level). Annotations are
used to *exclude* volumes or to add an *application-consistent logical dump* for stateful services such as
databases.
```yaml
services:
db:
image: bozemanpass/todo-db:stack
volumes:
- "pgdata:/var/lib/postgresql/data" # @stack backup-exclude
# Prefer a consistent logical dump over a file-level copy of a live database.
# @stack backup-command pg_dump -U postgres -d todos
# @stack backup-file-extension sql
backend:
image: bozemanpass/todo-backend:stack
volumes:
- "uploads:/app/uploads" # backed up by default (file-level)
volumes:
pgdata:
uploads:
```
| Annotation | Applies to | Meaning |
| ----------------------------------- | -------------- | ----------------------------------------------------------------------- |
| `@stack backup-exclude` | a volume mount | Do not include this volume in the file-level backup. |
| `@stack backup-command <cmd>` | a service | Run `<cmd>` in the container and capture its stdout into the backup. |
| `@stack backup-file-extension <ext>`| a service | Name the captured `backup-command` output with this extension. |
These map **one-to-one** onto K8up's pod annotations on the Kubernetes target (`k8up.io/backupcommand`,
`k8up.io/file-extension`, and PVC exclusion), and onto pre-backup hooks in the restic container on the
Docker target. The same annotations drive both.
### Why a logical dump matters
The ingress analogy is reassuring but slightly misleading: ingress is *stateless*, whereas backup is
*deeply stateful*. A file-level copy of a database's data directory **while it is being written** can
produce a torn, unrestorable snapshot. For such services, prefer `@stack backup-command` (e.g. `pg_dump`)
and `@stack backup-exclude` the underlying data volume.
## Enabling backup
As with ingress, the backup capability is **mixed in** to a deployment. The connection details for the
object store and the encryption key are supplied as configuration, the same way the ACME/Let's Encrypt
settings are supplied to the ingress stack.
```bash
# Fetch the backup stack.
$ stack fetch repo bozemanpass/backup-stack
# Configure the object store + encryption key for this deployment.
$ stack init --stack backup \
--output ~/specs/backup.yml \
--config BACKUP_S3_ENDPOINT=s3.us-west-2.amazonaws.com \
--config BACKUP_S3_BUCKET=my-stack-backups \
--config AWS_ACCESS_KEY_ID=AKIA... \
--config AWS_SECRET_ACCESS_KEY=... \
--config RESTIC_PASSWORD=... \
--config BACKUP_SCHEDULE="0 3 * * *" \
--config BACKUP_RETENTION="--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
# Init the application stack as usual.
$ stack fetch repo bozemanpass/example-todo-list
$ stack prepare --stack todo
$ stack init --stack todo --output ~/specs/todo.yml
# "Mix in" the backup stack at deployment time.
$ stack deploy \
--spec-file ~/specs/backup.yml \
--spec-file ~/specs/todo.yml \
--deployment-dir ~/deployments/todo
$ stack manage --dir ~/deployments/todo start
```
> The encryption key and object-store credentials are **secrets**. They live in the deployment's generated
> configuration (and as a Kubernetes `Secret`), never in a spec under git.
## How Docker backup works
Like ingress, the stack tool does **not** write the backup engine's job logic by hand. It generates
configuration for an existing restic container image and lets that image do the work. The backup service
cannot be a purely *static* mix-in, because at the time its image is built it does not know which volumes
the application stack will contribute β and naming them statically would collide with the merge step,
which requires unique volume names across mixed-in specs.
Instead, the volume wiring is **injected at deploy time**, precisely parallel to the way `deploy` injects
`VIRTUAL_HOST_MULTIPORTS` environment variables into matching services for ingress:
1. **Annotations to spec** β during `stack init`, the `@stack backup-*` annotations are parsed out of
`composefile.yml` into a `backup` section of the output spec.
2. **Spec to backup container** β during `stack deploy`, the tool reads the merged deployment's volumes
(`Spec.get_volumes()`) and:
- mounts every backed-up volume **read-only** into the backup container
(`- <volume>:/backup/<volume>:ro`), and
- injects the schedule, retention, object-store, and per-service `backup-command` hook settings as
environment / config for the container.
3. **The backup container runs restic on a schedule** β on each cron tick it runs any configured
pre-backup hooks (the logical dumps) and then `restic backup` of the mounted volume tree, pruning
per the retention policy.
Because every named volume on the Docker target is already realised as a bind mount under
`<deployment-dir>/data/<volume-name>/`, the set of paths to back up is fully deterministic.
## Kubernetes
On Kubernetes the work is delegated to [K8up](https://k8up.io), a restic-based backup operator. This
follows the same assume-present contract the stack tool already uses for `ingress-nginx` and
`cert-manager`: `stack` does not install K8up; it only emits resources that *reference* it.
- During `stack deploy`, the tool emits a K8up `Schedule` for the deployment's namespace (one namespace
per deployment), so all of its PVCs are backed up, together with `k8up.io/backupcommand` /
`k8up.io/file-extension` annotations derived from the `@stack backup-*` annotations.
- K8up writes a **standard restic repository** to the same object store, with the same encryption β so the
repositories are interchangeable with those produced on the Docker target.
K8up itself is provisioned by the **`cluster`** tool (the batteries-included checker/fixer for required
cluster components), exactly as `cluster` is responsible for `cert-manager` and `ingress-nginx`. A
backup-enabled deployment fails recognisably β the same way an ingress deployment fails today when
`cert-manager` is absent β if K8up has not been provisioned. The readiness probe is concrete: K8up's CRDs
registered and its operator `Deployment` healthy.
> Because the deployment uses a single node (or node affinity to co-locate data), K8up's backup `Job` can
> mount the `ReadWriteOnce` PVCs alongside the running application pod. Cross-node volume access is
> explicitly out of scope.
## Restore
Restore is deliberately modelled as a distinct mode rather than a live operation, which neatly sidesteps
the problem of two consumers mounting the same volume at once. At restore time **nothing else holds the
volumes**, so even `ReadWriteOnce` PVCs can be attached by the restore job.
The flow:
1. The full stack is stopped (if running).
2. The volumes are (re)created empty.
3. A **backup-only** variant of the deployment is brought up, running a restore of the chosen snapshot:
- **Docker:** bring up only the backup service with a restore command, restoring into the now-empty
bind-mounted volumes.
- **Kubernetes:** create a K8up `Restore` resource targeting the freshly-created PVCs.
4. The backup-only variant is torn down once the restore completes.
5. The full stack is started; its volumes now contain the restored data.
This is driven by a single command (see below) so the operator does not orchestrate the steps by hand.
## Command structure
Backup *configuration* is established at `init` / `deploy` time (above). Backup *operations* act on an
existing deployment, so they live under `stack manage --dir <dir>`, alongside `start`, `stop`, `ps`, and
`logs` β **not** under `stack deploy`, which only *creates* a deployment.
```
stack manage --dir <dir> backup <subcommand>
```
| Command | Description |
| -------------------------------------------------------------- | ------------------------------------------------------------------ |
| `stack manage --dir <dir> backup now` | Run a backup immediately, outside the schedule. |
| `stack manage --dir <dir> backup status` | Show the result of the last run and repository health. |
| `stack manage --dir <dir> backup list` | List snapshots (the available epochs) with timestamps and tags. |
| `stack manage --dir <dir> backup restore [--snapshot <id>]` | Orchestrate the full stop β restore β restart flow. Defaults to the latest snapshot; `--volume <name>` restores a single volume. `--and-start` restarts the full stack on completion. |
| `stack manage --dir <dir> backup prune` | Apply the retention policy (`forget` + `prune`). |
| `stack manage --dir <dir> backup check` | Verify repository integrity. |
`backup` is a Click sub-group of the existing `manage` group, so it inherits `--dir` and the deployment
context. Internally each subcommand dispatches to the active target: on Docker it `exec`s restic in the
backup container (or runs a one-off restore deployment); on Kubernetes it creates/reads the corresponding
K8up resources.
> The earlier sketch of `stack deploy backup status` is intentionally **not** the chosen shape: `deploy`
> creates a deployment from specs and exits, whereas backup status/restore/list are operations *on an
> existing* deployment β which is precisely what `manage` is for.
## Open questions
- **Engine choice.** restic is proposed for its simplicity and K8up compatibility. Kopia (Velero's default,
better at very large volumes) is the main alternative; both produce encrypted, deduplicated repositories,
but the repository formats are *not* mutually compatible, so this choice must be made once, up front.
- **Off-the-shelf Docker image.** Candidates that preserve the restic-repo format include
`mazzolino/restic` and `lobaro/restic-backup-docker`. (`offen/docker-volume-backup` is feature-rich but
defaults to tar+GPG, which would break cross-target compatibility.)
- **Secret delivery.** Exactly how the restic password and object-store credentials flow from
configuration into the Docker container env and the Kubernetes `Secret`.
- **Monitoring.** Surfacing backup success/failure (a healthcheck or status that `backup status` can read)
so that a silently failing backup is not mistaken for a working one.
- **New repository.** The Docker backup stack lives in its own repo (e.g. `bozemanpass/backup-stack`),
mirroring `bozemanpass/docker-ingress-stack`.
File created successfully at: /home/david/projects/bpi/stack/docs/backup.md (file state is current in your context β no need to Read it back)
Done β written to docs/backup.md, structured to mirror docs/ingress.md (annotations β enabling β how-it-works β k8s β restore), and clearly marked design proposal, not yet implemented so it isn't mistaken for shipped behaviour.
On the CLI question specifically, the codebase made the answer clear-cut. There are two relevant verbs today:
stack deploy creates a deployment from specs and exits.stack manage --dir <dir> <verb> is a Click group that operates on an existing deployment (start/stop/ps/logs/exec/port β deployment.py:44).Backup status/list/restore are operations on an existing deployment, so they belong under manage, not deploy. So rather than your stack deploy backup status sketch, the doc proposes:
stack manage --dir <dir> backup now # run a backup off-schedule
stack manage --dir <dir> backup status # last-run result + repo health
stack manage --dir <dir> backup list # snapshots = the epochs
stack manage --dir <dir> backup restore [--snapshot <id>] [--volume <name>] [--and-start]
stack manage --dir <dir> backup prune # apply retention
stack manage --dir <dir> backup check # verify repo integrity
backup is a sub-group of manage, so it inherits --dir and the deployment context for free, and each subcommand dispatches to the active target under the hood (exec restic in the container on Docker; create/read K8up resources on k8s). I called out the deploy vs manage reasoning explicitly in the doc so the choice is defensible on review. Configuration (S3 endpoint, credentials, encryption key, schedule, retention) stays at init/deploy time via --config, exactly like the ingress ACME settings.
Two things I flagged as open questions worth your call before any code:
Secret. This is a prerequisite, not an afterthought.Want me to refine any section β e.g. nail down the @stack backup-* annotation schema further, or sketch the deploy-time injection pass (the get_volumes() β backup-container-mounts code) as the first implementation step?
Yes you can work on refining the details. I think restic is a good choice. I am wondering about the need for the annotation scheme. Do you think it would be possible to just backup all volumes and provide any required metadata from the stack environment? Perhaps use annotation to override the default (e.g. skip backup for specific volumes). I'm concerned we keep the feature as transparent as possible, ideally just "backup my stuff" without any need to provide further information (where to backup and what credentials to use could be sourced from the environment by default).
Good instinct β "backup my stuff" with zero ceremony is the right north star. Let me check how the profile/config system works, because that's what determines how much can be sourced ambiently from the environment versus stated per-deployment.
ls src/stack/config/ && echo "=== config command ===" && grep -rn "def \|@click\|profile\|environ\|getenv\|os.environ" src/stack/config/*.py | head -40
__init__.py
__pycache__
config.py
util.py
=== config command ===
src/stack/config/config.py:24:def set_config_value(key, value):
src/stack/config/config.py:61:@click.group()
src/stack/config/config.py:62:@click.pass_context
src/stack/config/config.py:63:def command(ctx):
src/stack/config/config.py:70:@click.command()
src/stack/config/config.py:71:@click.pass_context
src/stack/config/config.py:72:def show(ctx):
src/stack/config/config.py:80:@click.command()
src/stack/config/config.py:81:@click.argument("key", required=True)
src/stack/config/config.py:82:@click.pass_context
src/stack/config/config.py:83:def get(ctx, key):
src/stack/config/config.py:99:@click.command()
src/stack/config/config.py:100:@click.argument("key", required=True)
src/stack/config/config.py:101:@click.argument("value", required=False)
src/stack/config/config.py:102:@click.pass_context
src/stack/config/config.py:103:def set(ctx, key, value):
src/stack/config/config.py:109:@click.command()
src/stack/config/config.py:110:@click.argument("key", required=True)
src/stack/config/config.py:111:@click.pass_context
src/stack/config/config.py:112:def unset(ctx, key):
src/stack/config/util.py:25:def get_config_dir():
src/stack/config/util.py:29:def get_config_file_path():
src/stack/config/util.py:30: return get_config_dir().joinpath(os.environ.get("STACK_CONFIG_PROFILE", "config") + ".yml")
src/stack/config/util.py:33:def get_config():
src/stack/config/util.py:42:def save_config(config):
src/stack/config/util.py:51:def get_config_setting(key, default=None):
src/stack/config/util.py:56: # Check environment first
src/stack/config/util.py:83:def _get_from_env(key):
src/stack/config/util.py:87: return os.environ.get(key, None)
src/stack/config/util.py:90:def _get_from_file(key):
src/stack/config/util.py:109:def get_dev_root_path():
src/stack/config/util.py:113:def debug_enabled():grep -rn "getenv\|os.environ\|environ\[" src/stack/ --include=*.py | grep -vi test | head -30
src/stack/base.py:50: url_from_environment = os.environ.get("STACK_NPM_REGISTRY_URL")
src/stack/util.py:29:STACK_USE_BUILTIN_STACK = "true" == os.environ.get("STACK_USE_BUILTIN_STACK", "false")
src/stack/util.py:54:def _sub_execute(cmd, env=os.environ, cwd=None, stdout_cb=output_subcmd, stderr_cb=output_subcmd):
src/stack/util.py:72:def run_shell_command(cmd, env=os.environ, cwd=None, quiet=False, check_result=True):
src/stack/constants.py:24:compose_file_prefix = os.environ.get("STACK_COMPOSE_FILE_PREFIX", "composefile")
src/stack/main.py:49: "--profile", help="name of the configuration profile to use", default=os.environ.get("STACK_CONFIG_PROFILE", "config")
src/stack/main.py:75: os.environ["STACK_CONFIG_PROFILE"] = command_options.profile
src/stack/main.py:77: os.environ["STACK_DEBUG"] = str(command_options.debug)
src/stack/main.py:79: os.environ["STACK_LOG_LEVEL"] = str(command_options.log_level)
src/stack/config/util.py:30: return get_config_dir().joinpath(os.environ.get("STACK_CONFIG_PROFILE", "config") + ".yml")
src/stack/config/util.py:87: return os.environ.get(key, None)
src/stack/build/build_util.py:201: if os.path.exists(f"{os.environ['XDG_RUNTIME_DIR']}/containers/auth.json"):
src/stack/build/build_util.py:202: auths = json.load(open(f"{os.environ['XDG_RUNTIME_DIR']}/containers/auth.json", "rt")).get("auths", {})
src/stack/deploy/deploy.py:67: os.environ[attr] = value
src/stack/deploy/deploy.py:295: command_env = os.environ.copy()
src/stack/build/build_containers.py:69: "DOCKER_BUILDKIT": os.environ.get("DOCKER_BUILDKIT", default="1"),
src/stack/build/build_containers.py:74: docker_host_env = os.getenv("DOCKER_HOST")
src/stack/build/build_containers.py:163: build_envs["PATH"] = os.environ["PATH"]
src/stack/deploy/webapp/util.py:78: github_token = os.environ.get("DEPLOYER_GITHUB_TOKEN")
src/stack/deploy/webapp/util.py:91: git_env = dict(os.environ.copy())
src/stack/deploy/k8s/helpers.py:296:def _expand_shell_vars(raw_val: str, environ=os.environ) -> str:
src/stack/deploy/k8s/helpers.py:310:def envs_from_compose_file(compose_file_envs: Mapping[str, str], environ=os.environ) -> Mapping[str, str]:This is the key piece. Let me read the config-resolution function to confirm the precedence.
{
"file_path": "/home/david/projects/bpi/stack/src/stack/config/util.py",
"offset": 51,
"limit": 45
}51 def get_config_setting(key, default=None):
52 key = key.lower().replace("_", "-")
53 if key.startswith("stack-"):
54 key = key[6:]
55
56 # Check environment first
57 ret = _get_from_env(key)
58
59 # Next check file
60 if ret is None:
61 ret = _get_from_file(key)
62
63 # Last check defaults
64 if ret is None:
65 # But only if we didn't get a default already
66 if default is None:
67 ret = _DEFAULTS.get(key, None)
68
69 if ret is not None:
70 # If it is a ~/ path, expand it.
71 if str(ret).startswith("~/"):
72 ret = os.path.expanduser(str(ret))
73 # And parse a boolean if it is a string.
74 if str(ret).lower() in ["true", "false"]:
75 ret = str(ret).lower() == "true"
76
77 if ret is None:
78 return default
79
80 return ret
81
82
83 def _get_from_env(key):
84 key = key.upper().replace("-", "_")
85 if not key.startswith("STACK_"):
86 key = "STACK_" + key
87 return os.environ.get(key, None)
88
89
90 def _get_from_file(key):
91 config = get_config()
92
93 parts = key.split(".")
94 if len(parts) == 1:
95 return config.get(key, None)That confirms the mechanism that makes your "zero ceremony" goal achievable. get_config_setting() (config/util.py:51) already resolves every setting with the precedence environment variable β profile file β built-in default. So backup destination, credentials, schedule, and retention can all be ambient: set once in a profile (or exported as STACK_* env vars), inherited by every deployment, never restated per-stack.
That reshapes the design substantially in the direction you want. Here's my thinking, then I'll rework the doc.
Spec.get_volumes()). The tool already knows them. No annotation required.env β profile β default chain (STACK_BACKUP_S3_BUCKET, etc.). Configure a profile once; every deploy "just backs up."So a stack with zero annotations and zero per-deploy flags gets all its volumes backed up. That's the "backup my stuff" baseline.
The key reframe: annotations are author-time metadata in the component's composefile.yml, not deployer-time input. Just like the ingress # @stack http-proxy /api annotations, they're written once by whoever authors the stack; the person deploying never sees or touches them. So keeping them does not violate your transparency goal β the deployer still types nothing.
I'd keep exactly two, both optional, both with safe defaults:
@stack backup-exclude (opt-out) β for volumes that are caches / reconstructable scratch / huge and pointless to store. Absent β the volume is backed up. Safe default is "back up."@stack backup-command (consistency) β the one genuinely irreducible escape hatch. This is the honest caveat: a file-level copy of a live database volume can be torn and unrestorable. The component author adds one line (pg_dump β¦); the deployer is none the wiser. Absent β plain file-level backup.Everything else I'd drop. No per-volume "include" annotation, no required schema.
This is the trap in "source everything from the environment." restic cannot decrypt without its password. If the password is auto-generated and lives only in the profile/environment, then losing that environment means the backups are permanently unrecoverable β you'd have an encrypted bucket you can never read. So the restic password must either be explicitly set by the operator, or auto-generated and persisted + surfaced for escrow. I'll flag this prominently rather than let it hide inside "ambient config."
Rather than the deployer mixing in a backup.yml full of credentials, I'd make backup a profile-level capability: enabled by a single profile setting (or --backup flag), after which destination/creds/schedule are all ambient. On Docker that auto-injects the backup container at deploy time; on k8s it emits the K8up Schedule. Default off, one knob to turn it on, nothing further.
Let me rework the doc to reflect all this.
# Backup & Restore
> **Status: design proposal β not yet implemented.** This document describes the intended design for
> backing up and restoring service data. It is written to be reviewed and refined before any code is
> written. Where it describes commands or behaviour, read those as *proposed*.
Stacks keep their software components and configuration under revision control in git. Once a stack is
running, however, it accumulates **persistent data** in mounted volumes that git does not track. This
document describes how that data is backed up to object storage (S3) and restored from a previous epoch.
## Design goal: "backup my stuff", and nothing more
The feature is designed to be as transparent as possible. In the common case the person deploying a stack
provides **no backup-specific information at all** β not which volumes to back up, not where to send them,
not on what schedule. Backup is configured once at the environment/profile level and then applies to every
deployment automatically.
This is achieved by deriving everything possible from what the stack tool already knows or can source
ambiently:
- **What to back up** is derived from the deployment itself: *all* read-write named volumes are backed up
by default. The tool already enumerates them (`Spec.get_volumes()`).
- **Where to back up, with which credentials, on what schedule** is sourced from the environment using the
existing configuration precedence (see [Configuration](#configuration)).
Annotations exist only to *refine* this default, and are written by the **author** of a stack, never by
the person deploying it.
## Engine: restic
All backups β on both targets β are stored as a [restic](https://restic.net) repository. restic is the
contract, not an implementation detail:
- **Client-side encryption is mandatory** (AES-256). The payload is encrypted *before* upload, so the
object store never sees plaintext. This is what makes backing up to commodity object storage acceptable.
- **Content-addressed dedup + incremental snapshots.** A daily backup of a mostly-static volume costs
almost nothing.
- **Snapshots and retention policies** give point-in-time restore (your "previous epoch").
- **Native S3 backend** (and any S3-compatible store: MinIO, Wasabi, etc.).
Standardising on the restic repository format means a backup written on the Docker target is restorable on
the Kubernetes target and vice-versa, and that in a pinch an operator can restore with the bare `restic`
CLI from outside the deployment entirely.
The two targets differ only in *what runs restic and how it is scheduled*:
| Concern | Docker | Kubernetes |
| ------------------ | ------------------------------------------------- | -------------------------------------------- |
| Engine / format | restic (off-the-shelf restic container image) | restic (via **K8up**) |
| Scheduling | cron in the backup container | K8up `Schedule` resource |
| Quiesce / hooks | pre/post hooks in the backup container | `k8up.io/backupcommand` pod annotation |
| Config generation | `stack` injects config at deploy time | `stack` emits K8up resources |
| Prerequisites | the auto-injected backup container | the `cluster` tool ensures K8up is present |
| Restore | start-stripped β restic restore β start full | K8up `Restore` into freshly-created PVCs |
## Configuration
Backup settings are resolved with the standard stack configuration precedence
(`config/util.py:get_config_setting`): **environment variable β active profile β built-in default**. This
is what lets backup be ambient β set it once in a profile and every deployment under that profile inherits
it, with no per-stack input.
| Setting (profile key / `STACK_β¦` env var) | Purpose | Default |
| ------------------------------------------- | ----------------------------------------------- | -------------- |
| `backup` / `STACK_BACKUP` | Master switch β enable backup for deployments. | `false` |
| `backup-s3-endpoint` | Object store endpoint. | β |
| `backup-s3-bucket` | Bucket / repository location. | β |
| `aws-access-key-id`, `aws-secret-access-key`| Object store credentials. | β |
| `restic-password` | **Encryption key** (see warning below). | β |
| `backup-schedule` | Cron schedule. | `0 3 * * *` |
| `backup-retention` | `forget`/`prune` policy. | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` |
Typical one-time setup for an environment:
```bash
$ stack config set backup true
$ stack config set backup-s3-endpoint s3.us-west-2.amazonaws.com
$ stack config set backup-s3-bucket my-stack-backups
$ stack config set aws-access-key-id AKIA...
$ stack config set aws-secret-access-key ...
$ stack config set restic-password ...
```
After that, **every** deployment is backed up with no further action:
```bash
$ stack deploy --spec-file ~/specs/todo.yml --deployment-dir ~/deployments/todo
$ stack manage --dir ~/deployments/todo start
# ...the deployment's volumes are now backed up on the configured schedule.
```
> #### β The encryption key cannot be purely ephemeral
>
> restic cannot decrypt a repository without its password. If `restic-password` is auto-generated and
> lives *only* in an environment that is later lost, the backups become **permanently unrecoverable** β an
> encrypted bucket that can never be read. The password must therefore either be set explicitly by the
> operator, or be auto-generated **and persisted and surfaced for the operator to escrow**. This is the one
> piece of backup configuration that must not be treated as disposable ambient state. Object-store
> credentials, by contrast, can be rotated freely.
## Volume selection (automatic)
By default **all read-write named volumes** in a deployment are backed up, file-level. Read-only mounts and
config maps are skipped. No annotation or flag is required for this β it is derived entirely from the
merged spec.
The only optional refinement is to *exclude* a volume that is a cache, scratch space, or otherwise cheaply
reconstructable, using an annotation in the stack's `composefile.yml`:
```yaml
services:
backend:
image: bozemanpass/todo-backend:stack
volumes:
- "uploads:/app/uploads" # backed up by default
- "cache:/app/cache" # @stack backup-exclude
```
Excluding a volume is an **author** decision encoded in the component, not something the deployer supplies.
## Application consistency
This is the one place where "just back up everything" needs care, and it is worth stating plainly: the
ingress analogy is misleading because ingress is *stateless* whereas backup is *deeply stateful*. A
file-level copy of a **live database's** data directory, read file-by-file while the database writes, can
produce a torn, unrestorable snapshot.
For such services the stack author adds a single annotation specifying a logical dump command, whose stdout
is captured into the backup instead of (or alongside) the raw files:
```yaml
services:
db:
image: bozemanpass/todo-db:stack
volumes:
- "pgdata:/var/lib/postgresql/data" # @stack backup-exclude
# @stack backup-command pg_dump -U postgres -d todos
# @stack backup-file-extension sql
```
Crucially this is **author-time** metadata: whoever packages the database component writes it once, and
every deployer of that stack gets consistent backups for free, having supplied nothing. The annotation maps
one-to-one onto K8up's `k8up.io/backupcommand` / `k8up.io/file-extension` pod annotations on the Kubernetes
target, and onto a pre-backup hook in the restic container on the Docker target.
### Annotation summary
There are only two optional annotations, both author-time, both with safe "just back it up" defaults:
| Annotation | Applies to | Meaning |
| ----------------------------------- | -------------- | ----------------------------------------------------------------------- |
| `@stack backup-exclude` | a volume mount | Do not include this volume in the file-level backup. |
| `@stack backup-command <cmd>` | a service | Capture `<cmd>`'s stdout into the backup (e.g. a consistent DB dump). |
| `@stack backup-file-extension <ext>`| a service | Name the captured `backup-command` output with this extension. |
A stack that uses none of these is still fully backed up β every read-write volume, file-level.
## How Docker backup works
Like ingress, the stack tool does **not** write the backup engine's job logic by hand. It generates
configuration for an existing restic container image and lets that image do the work. The backup container
cannot be a purely *static* mix-in, because at the time its image is built it does not know which volumes
the application stack will contribute β and naming them statically would collide with the merge step, which
requires unique volume names across mixed-in specs.
Instead, when the `backup` master switch is enabled, the backup container is **injected at deploy time**,
precisely parallel to the way `deploy` injects `VIRTUAL_HOST_MULTIPORTS` environment variables into
matching services for ingress:
1. **Annotations to spec** β during `stack init`, any `@stack backup-*` annotations are parsed out of
`composefile.yml` into a `backup` section of the output spec.
2. **Spec to backup container** β during `stack deploy`, the tool reads the merged deployment's volumes
(`Spec.get_volumes()`) and:
- mounts every non-excluded read-write volume **read-only** into the backup container
(`- <volume>:/backup/<volume>:ro`), and
- injects the ambiently-resolved schedule, retention, object-store, and per-service `backup-command`
hook settings as environment / config for the container.
3. **The backup container runs restic on a schedule** β on each cron tick it runs any configured
pre-backup hooks (the logical dumps) and then `restic backup` of the mounted volume tree, applying the
retention policy.
Because every named volume on the Docker target is already realised as a bind mount under
`<deployment-dir>/data/<volume-name>/`, the set of paths to back up is fully deterministic.
## Kubernetes
On Kubernetes the work is delegated to [K8up](https://k8up.io), a restic-based backup operator. This
follows the same assume-present contract the stack tool already uses for `ingress-nginx` and `cert-manager`
(it references an `ingress_class_name="nginx"` and a `cert-manager.io/cluster-issuer` it does not install):
`stack` does not install K8up; it only emits resources that *reference* it.
- During `stack deploy`, the tool emits a K8up `Schedule` for the deployment's namespace (one namespace per
deployment), so all of its PVCs are backed up, together with `k8up.io/backupcommand` /
`k8up.io/file-extension` annotations derived from the `@stack backup-*` annotations.
- K8up writes a **standard restic repository** to the same object store, with the same encryption β so the
repositories are interchangeable with those produced on the Docker target.
K8up itself is provisioned by the **`cluster`** tool (the batteries-included checker/fixer for required
cluster components), exactly as `cluster` is responsible for `cert-manager` and `ingress-nginx`. A
backup-enabled deployment fails recognisably β the same way an ingress deployment fails today when
`cert-manager` is absent β if K8up has not been provisioned. The readiness probe is concrete: K8up's CRDs
registered and its operator `Deployment` healthy.
> Because the deployment uses a single node (or node affinity to co-locate data), K8up's backup `Job` can
> mount the `ReadWriteOnce` PVCs alongside the running application pod. Cross-node volume access is
> explicitly out of scope.
## Restore
Restore is deliberately modelled as a distinct mode rather than a live operation, which neatly sidesteps the
problem of two consumers mounting the same volume at once. At restore time **nothing else holds the
volumes**, so even `ReadWriteOnce` PVCs can be attached by the restore job.
The flow:
1. The full stack is stopped (if running).
2. The volumes are (re)created empty.
3. A **backup-only** variant of the deployment is brought up, running a restore of the chosen snapshot:
- **Docker:** bring up only the backup container with a restore command, restoring into the now-empty
bind-mounted volumes.
- **Kubernetes:** create a K8up `Restore` resource targeting the freshly-created PVCs.
4. The backup-only variant is torn down once the restore completes.
5. The full stack is started; its volumes now contain the restored data.
This is driven by a single command (see below) so the operator does not orchestrate the steps by hand.
## Command structure
Backup *configuration* is ambient (above) and backup runs automatically. The commands below are for
*operating on* an existing deployment β inspecting, triggering off-schedule, and restoring β so they live
under `stack manage --dir <dir>`, alongside `start`, `stop`, `ps`, and `logs`. They are **not** under
`stack deploy`, which only *creates* a deployment and exits.
```
stack manage --dir <dir> backup <subcommand>
```
| Command | Description |
| -------------------------------------------------------------- | ------------------------------------------------------------------ |
| `stack manage --dir <dir> backup now` | Run a backup immediately, outside the schedule. |
| `stack manage --dir <dir> backup status` | Show the result of the last run and repository health. |
| `stack manage --dir <dir> backup list` | List snapshots (the available epochs) with timestamps and tags. |
| `stack manage --dir <dir> backup restore [--snapshot <id>]` | Orchestrate the full stop β restore β restart flow. Defaults to the latest snapshot; `--volume <name>` restores a single volume; `--and-start` restarts the full stack on completion. |
| `stack manage --dir <dir> backup prune` | Apply the retention policy (`forget` + `prune`). |
| `stack manage --dir <dir> backup check` | Verify repository integrity. |
`backup` is a Click sub-group of the existing `manage` group, so it inherits `--dir` and the deployment
context. Internally each subcommand dispatches to the active target: on Docker it `exec`s restic in the
backup container (or runs a one-off restore deployment); on Kubernetes it creates/reads the corresponding
K8up resources.
> The earlier sketch of `stack deploy backup status` is intentionally **not** the chosen shape: `deploy`
> creates a deployment from specs and exits, whereas backup status/restore/list are operations *on an
> existing* deployment β which is precisely what `manage` is for.
## Open questions
- **Auto-enable vs explicit switch.** This design gates backup on an explicit `backup` master switch
(profile setting / `STACK_BACKUP`) so behaviour is predictable. The most transparent alternative β
enabling backup automatically whenever a destination is configured β is rejected as too implicit, but is
worth revisiting.
- **Encryption-key escrow.** The concrete mechanism for persisting and surfacing an auto-generated
`restic-password` so it cannot be silently lost (see the warning above).
- **Off-the-shelf Docker image.** Candidates that preserve the restic-repo format include `mazzolino/restic`
and `lobaro/restic-backup-docker`. (`offen/docker-volume-backup` is feature-rich but defaults to
tar+GPG, which would break cross-target compatibility.)
- **Monitoring.** Surfacing backup success/failure (a healthcheck or status that `backup status` can read)
so that a silently failing backup is not mistaken for a working one.
- **New repository.** The Docker backup container lives in its own repo (e.g. `bozemanpass/backup-stack`),
mirroring `bozemanpass/docker-ingress-stack`.
The file /home/david/projects/bpi/stack/docs/backup.md has been updated successfully. (file state is current in your context β no need to Read it back)
Reworked docs/backup.md around your transparency goal. The headline change: backup is now ambient by default, not annotation-driven.
Direct answer to your question: yes β we can back up all volumes with zero deployer input, and I've made that the design. Annotations are demoted to optional author-time refinements only.
What changed:
New "Design goal" section up front β states the contract: the person deploying provides nothing. What-to-back-up is derived from the deployment; where/credentials/schedule come from the environment.
Volume selection is automatic β all read-write named volumes, derived from Spec.get_volumes(). No annotation needed. The only optional one, backup-exclude, is for caches/scratch and is an author decision in the component, never the deployer's.
Configuration is sourced ambiently β I anchored this on the real mechanism I found: get_config_setting() already resolves env β profile β default. So stack config set backup-s3-bucket β¦ once per profile, and every deployment inherits it. Documented the exact setting keys.
Two honest caveats I refused to hand-wave:
restic-password lives only in a lost environment, the backups are permanently unrecoverable. It must be explicitly set or escrowed. This is the one thing "source everything from the environment" gets dangerously wrong if done naively.Database consistency. File-level copy of a live DB can be torn. The remedy is the single remaining meaningful annotation, @stack backup-command pg_dump β¦ β but I stressed it's written once by the component author, so the deployer still supplies nothing.
Enabling is one switch β a backup master setting (profile / STACK_BACKUP), default off. Turn it on once; nothing further per-stack. On Docker it auto-injects the backup container at deploy; on k8s it emits the K8up Schedule.
So the annotation scheme shrank from a required per-volume system to two optional author-time hints, neither of which the deployer ever touches β and a stack with no annotations at all is still fully backed up.
The one design decision I left open for you (flagged in "Open questions"): I gated backup on an explicit master switch rather than auto-enabling it the moment a destination is configured. Explicit is more predictable; fully-implicit is marginally more transparent but can surprise. I'd lean explicit, but it's your call.
Want me to now sketch the deploy-time injection pass β the actual get_volumes() β backup-container read-only mounts code β as the first concrete implementation step, or leave it at the design doc for now?
Ok yes explicit enabling is fine. We can revisit this kind of detail after acquiring some operational experience. Please proceed.
I'll ground the sketch in how the ingress injection actually works in code, then mirror it. Let me find the real VIRTUAL_HOST_MULTIPORTS injection path.
grep -rn "VIRTUAL_HOST\|http_proxy\|http-proxy\|LETSENCRYPT" src/stack/deploy/*.py | grep -vi "def get_http_proxy\|_http_proxy\b" | head -40
src/stack/deploy/explain.py:64: use_tls = http_proxy_info and not is_kind
src/stack/deploy/deployment_create.py:269: http_proxy_fqdn,
src/stack/deploy/deployment_create.py:270: http_proxy_clusterissuer,
src/stack/deploy/deployment_create.py:271: http_proxy_targets,
src/stack/deploy/deployment_create.py:285: if http_proxy_targets:
src/stack/deploy/deployment_create.py:287: for target in http_proxy_targets:
src/stack/deploy/deployment_create.py:294: http_proxy = {
src/stack/deploy/deployment_create.py:295: constants.host_name_key: http_proxy_fqdn,
src/stack/deploy/deployment_create.py:296: constants.cluster_issuer_key: http_proxy_clusterissuer,
src/stack/deploy/deployment_create.py:301: spec_file_content[constants.network_key].update({constants.http_proxy_key: [http_proxy]})
src/stack/deploy/deployment_create.py:307: if http_proxy_targets:
src/stack/deploy/deployment_create.py:309: for target in http_proxy_targets:
src/stack/deploy/deployment_create.py:316: http_proxy = {
src/stack/deploy/deployment_create.py:317: constants.host_name_key: http_proxy_fqdn,
src/stack/deploy/deployment_create.py:320: if http_proxy_clusterissuer and deployer_type in ["k8s", "k8s-kind"]:
src/stack/deploy/deployment_create.py:321: http_proxy[constants.cluster_issuer_key] = http_proxy_clusterissuer
src/stack/deploy/deployment_create.py:326: spec_file_content[constants.network_key].update({constants.http_proxy_key: [http_proxy]})
src/stack/deploy/deployment_create.py:551: for pxy in http_proxy_config:
src/stack/deploy/deployment_create.py:571: add_env_var("VIRTUAL_HOST_MULTIPORTS", json.dumps(vhost), svc_env)
src/stack/deploy/deployment_create.py:573: add_env_var("LETSENCRYPT_HOST", host, svc_env)
src/stack/deploy/stack.py:203: http_proxy_targets = []
src/stack/deploy/stack.py:216: if constants.stack_annotation_marker in comment and constants.http_proxy_key in comment:
src/stack/deploy/stack.py:218: parts = parts[parts.index(constants.http_proxy_key) + 1 :]
src/stack/deploy/stack.py:225: http_proxy_targets.append({"service": svc_name, "port": port, "path": path})
src/stack/deploy/stack.py:226: return http_proxy_targets
src/stack/deploy/stack.py:312: return s.get(constants.http_proxy_prefix_key, None)
src/stack/deploy/spec.py:141: return self.obj.get(constants.network_key, {}).get(constants.http_proxy_key, [])
src/stack/deploy/spec.py:145: if constants.http_proxy_key in self.obj[constants.network_key]:
src/stack/deploy/spec.py:146: del self.obj[constants.network_key][constants.http_proxy_key]
src/stack/deploy/spec.py:151: self.obj[constants.network_key][constants.http_proxy_key] = valgrep -rn "VIRTUAL_HOST\|def .*proxy\|inject\|environment\|composefile\|compose_file\|parsed_pod\|fixup_pod" src/stack/deploy/deployment_create.py | head -40
src/stack/deploy/deployment_create.py:77:def _fixup_pod_file(pod, spec, compose_dir):
src/stack/deploy/deployment_create.py:175:def _find_extra_config_dirs(parsed_pod_file, pod):
src/stack/deploy/deployment_create.py:177: services = parsed_pod_file["services"]
src/stack/deploy/deployment_create.py:522: parsed_pod_file = parsed_spec.load_pod_file(pod)
src/stack/deploy/deployment_create.py:523: extra_config_dirs = _find_extra_config_dirs(parsed_pod_file, pod)
src/stack/deploy/deployment_create.py:527: _fixup_pod_file(parsed_pod_file, parsed_spec, destination_compose_dir)
src/stack/deploy/deployment_create.py:531: services = parsed_pod_file["services"]
src/stack/deploy/deployment_create.py:552: svc_env = service_info.get("environment", {})
src/stack/deploy/deployment_create.py:571: add_env_var("VIRTUAL_HOST_MULTIPORTS", json.dumps(vhost), svc_env)
src/stack/deploy/deployment_create.py:574: service_info["environment"] = svc_env
src/stack/deploy/deployment_create.py:576: with open(destination_compose_dir.joinpath(f"{constants.compose_file_prefix}-%s.yml" % pod), "w") as output_file:
src/stack/deploy/deployment_create.py:577: yaml.dump(parsed_pod_file, output_file){
"file_path": "/home/david/projects/bpi/stack/src/stack/deploy/deployment_create.py",
"offset": 505,
"limit": 78
}505 parsed_spec.merge_stacks().dump(deployment_dir_path.joinpath(constants.stack_file_name))
506 else:
507 parsed_spec.load_stack().dump(deployment_dir_path.joinpath(constants.stack_file_name))
508
509 # Copy any config variables from the spec file into an env file suitable for compose
510 _write_config_file(parsed_spec, deployment_dir_path.joinpath(constants.config_file_name))
511
512 # Copy any k8s config file into the deployment dir
513 if deployment_type == "k8s":
514 _write_kube_config_file(
515 Path(parsed_spec.get_kube_config()),
516 deployment_dir_path.joinpath(constants.kube_config_filename),
517 )
518
519 yaml = get_yaml()
520 pods = parsed_spec.get_pod_list()
521 for pod in pods:
522 parsed_pod_file = parsed_spec.load_pod_file(pod)
523 extra_config_dirs = _find_extra_config_dirs(parsed_pod_file, pod)
524 destination_pod_dir = destination_pods_dir.joinpath(pod)
525 os.mkdir(destination_pod_dir)
526 log_debug(f"extra config dirs: {extra_config_dirs}")
527 _fixup_pod_file(parsed_pod_file, parsed_spec, destination_compose_dir)
528
529 if deployment_type == "compose":
530 # Inject the shared config.env file into the compose file. We don't need to do this for k8s.
531 services = parsed_pod_file["services"]
532 for service_name in services:
533 service_info = services[service_name]
534 image_name = service_info["image"]
535 if image_name.endswith(":stack"):
536 service_info["image"] = image_name[:-5] + deployment_command_context.cluster_context.cluster
537
538 shared_cfg_file = os.path.join(
539 "../" * len(destination_compose_dir.relative_to(deployment_dir_path).parts), constants.config_file_name
540 )
541 if "env_file" in service_info:
542 env_files = service_info["env_file"]
543 if isinstance(env_files, list):
544 service_info["env_file"] = [shared_cfg_file, *env_files]
545 else:
546 service_info["env_file"] = [shared_cfg_file, env_files]
547 else:
548 service_info["env_file"] = [shared_cfg_file]
549
550 http_proxy_config = parsed_spec.get_http_proxy()
551 for pxy in http_proxy_config:
552 svc_env = service_info.get("environment", {})
553 host = pxy[constants.host_name_key]
554 vhost = {host: {}}
555 for r in pxy[constants.routes_key]:
556 pxy_svc, pxy_port = r[constants.proxy_to_key].split(":", 1)
557 if pxy_svc == service_name:
558 path = "/" + r[constants.path_key].strip("/")
559 path_rule = path
560 dest = "/"
561 if path_rule != "/":
562 path_rule = f"~ ^{path_rule}(?:/(.*))?$"
563 dest = "/$1"
564
565 vhost[host][path_rule] = {
566 "dest": dest,
567 "port": pxy_port,
568 }
569
570 if vhost[host]:
571 add_env_var("VIRTUAL_HOST_MULTIPORTS", json.dumps(vhost), svc_env)
572 if "localhost" != host and "." in host:
573 add_env_var("LETSENCRYPT_HOST", host, svc_env)
574 service_info["environment"] = svc_env
575
576 with open(destination_compose_dir.joinpath(f"{constants.compose_file_prefix}-%s.yml" % pod), "w") as output_file:
577 yaml.dump(parsed_pod_file, output_file)
578
579 parsed_stack = parsed_spec.stack_for_pod(pod) if isinstance(parsed_spec, MergedSpec) else parsed_spec.load_stack()
580
581 # Copy the config files for the pod, if any
582 config_dirs = {pod}{
"file_path": "/home/david/projects/bpi/stack/src/stack/deploy/stack.py",
"offset": 200,
"limit": 28
}200 prefix = "/" + prefix
201 prefix = prefix.rstrip("/")
202
203 http_proxy_targets = []
204 pods = self.get_pod_list()
205 for pod in pods:
206 parsed_pod_file = self.load_pod_file(pod)
207 if constants.services_key in parsed_pod_file:
208 for svc_name, svc in parsed_pod_file[constants.services_key].items():
209 if constants.ports_key in svc:
210 ports_section = svc[constants.ports_key]
211 for i, port in enumerate(ports_section):
212 port = str(port).split(":")[-1]
213 if len(ports_section.ca.items) > 0:
214 if i in ports_section.ca.items:
215 comment = ports_section.ca.items[i][0].value.strip()
216 if constants.stack_annotation_marker in comment and constants.http_proxy_key in comment:
217 parts = comment.split()
218 parts = parts[parts.index(constants.http_proxy_key) + 1 :]
219 path = ""
220 if len(parts) >= 1:
221 path = parts[0]
222 if prefix:
223 path = f"{prefix}/{path.strip('/')}"
224 path = "/" + path.strip("/")
225 http_proxy_targets.append({"service": svc_name, "port": port, "path": path})
226 return http_proxy_targets
227 I now have the exact injection pattern. The ingress path is: stack.py:203 parses @stack annotations into targets β stored in the spec β deployment_create.py:550-574 injects env vars into the generated compose at deploy time. I'll mirror that precisely.
Since this feature is still pre-implementation (no backup-stack image exists yet, the master switch isn't plumbed through the CLI, and there's nothing to test against), committing live code into main would be premature. The right "first concrete step" is a code-grounded implementation sketch pinned to the real functions β executable as a plan, mirroring ingress exactly. Writing it as a companion doc.
# Backup β implementation sketch
> **Status: design sketch β not yet implemented.** Companion to [backup.md](./backup.md). This pins the
> design down to concrete functions and insertion points in the existing code, mirroring how ingress is
> implemented, so the work can be reviewed before it is written. Code below is illustrative, not final.
## Scope of this sketch
This covers the **deploy-time injection pass** (the first concrete step) and the two things it depends on:
1. parsing the `@stack backup-*` annotations into the spec (mirrors `Stack.get_http_proxy_targets`);
2. an accessor for that spec section (mirrors `Spec.get_http_proxy`);
3. injecting a backup service into the generated deployment at deploy time (mirrors the
`VIRTUAL_HOST_MULTIPORTS` injection).
**Out of scope here** (later steps): the `bozemanpass/backup-stack` container image, the
`stack manage β¦ backup` subcommands, and the Kubernetes K8up-resource emission. Those are tracked in
[backup.md](./backup.md).
## The ingress pattern we are mirroring
For reference, ingress works in three touch-points, all of which have a backup analogue:
| Ingress | Backup analogue |
| --------------------------------------------------------- | --------------------------------------------------------- |
| `Stack.get_http_proxy_targets()` parses port annotations (`stack.py:203`) | `Stack.get_backup_targets()` parses volume/service annotations |
| targets written into `network.http-proxy` spec section | targets written into a `backup` spec section |
| `Spec.get_http_proxy()` accessor (`spec.py:140`) | `Spec.get_backup()` accessor |
| inject `VIRTUAL_HOST_MULTIPORTS` env at deploy (`deployment_create.py:550`) | inject a `backup` service + `:ro` mounts at deploy |
## 1. Parse annotations β `deploy/stack.py`
Add a method alongside `get_http_proxy_targets` (`stack.py:203`). Unlike ingress, backup annotations attach
to two different places β a **volume mount line** (`backup-exclude`) and a **service** (`backup-command`,
`backup-file-extension`). Both are read from `ruamel`'s comment attributes (`.ca`), exactly as the ingress
parser reads port-line comments.
```python
# constants.py (new)
backup_key = "backup"
backup_exclude_annotation = "backup-exclude"
backup_command_annotation = "backup-command"
backup_file_extension_annotation = "backup-file-extension"
# deploy/stack.py β new method on Stack
def get_backup_targets(self):
"""Parse @stack backup-* annotations from the stack's composefiles.
Returns {"exclude": [volume_name, ...],
"commands": {service_name: {"command": str, "file_extension": str}}}
"""
exclude = []
commands = {}
for pod in self.get_pod_list():
parsed_pod_file = self.load_pod_file(pod)
for svc_name, svc in parsed_pod_file.get(constants.services_key, {}).items():
# Service-level annotations (backup-command / backup-file-extension) live in the
# comment block attached to the service mapping.
for ann in _stack_annotations_for(svc): # small helper over svc.ca
if constants.backup_command_annotation in ann:
commands.setdefault(svc_name, {})["command"] = _annotation_value(ann)
elif constants.backup_file_extension_annotation in ann:
commands.setdefault(svc_name, {})["file_extension"] = _annotation_value(ann)
# Volume-level annotation (backup-exclude) lives on the mount line, like ports do.
volumes_section = svc.get(constants.volumes_key, [])
for i, mount in enumerate(volumes_section):
comment = _line_comment(volumes_section, i) # mirrors ports_section.ca.items[i]
if comment and constants.stack_annotation_marker in comment \
and constants.backup_exclude_annotation in comment:
exclude.append(str(mount).split(":")[0])
return {"exclude": exclude, "commands": commands}
```
`_line_comment()` is the same `ports_section.ca.items[i][0].value` access used at `stack.py:213-215`,
factored out so it can be reused for the volumes list.
## 2. Spec section + accessor β `deploy/spec.py`
The parsed targets are written into a top-level `backup` section of the spec during `stack init` (alongside
where `http-proxy` targets are written, `deployment_create.py:285-326`). Add the read accessor next to
`get_http_proxy` (`spec.py:140`):
```python
# deploy/spec.py
def get_backup(self):
return self.obj.get(constants.backup_key, {})
```
Because `backup` is a plain dict it merges additively across mixed-in specs with no special handling, like
the other spec sections.
## 3. Inject the backup service at deploy time β `deploy/deployment_create.py`
This is the core of the pass. After the per-pod compose files are written (the loop ending at
`deployment_create.py:577`), and only for the `compose` target with the master switch enabled, write **one
synthetic compose file** containing the backup service.
The backup service mounts the deployment's data **read-only**. On Docker every named volume is already a
bind mount under `<deployment-dir>/data/<volume-name>/` (`_fixup_pod_file`, line 77), so the backup service
can bind-mount those host paths directly β sidestepping cross-compose-file named-volume declaration
entirely.
```python
# deploy/deployment_create.py β after the `for pod in pods:` loop (~line 577)
from stack.config.util import get_config_setting
def _maybe_write_backup_compose(parsed_spec, deployment_type, deployment_dir_path, destination_compose_dir):
if deployment_type != "compose":
return # k8s path emits K8up resources instead (separate step)
if not get_config_setting("backup", False): # explicit master switch (env β profile β default false)
return
backup_cfg = parsed_spec.get_backup()
exclude = set(backup_cfg.get("exclude", []))
# All read-write named volumes, minus author-excluded ones.
volumes = parsed_spec.get_volumes() # {name: host_path_or_None}
backed_up = [v for v in volumes if v not in exclude]
mounts = [f"../../data/{v}:/backup/{v}:ro" for v in backed_up] # relative to the compose dir
# Per-service consistent-dump hooks (run inside the backup container, reaching services by name
# over the shared compose network).
pre_hooks = []
for svc, c in backup_cfg.get("commands", {}).items():
ext = c.get("file_extension", "dump")
pre_hooks.append(f"{svc}:{c['command']}:{ext}") # parsed by the backup image entrypoint
backup_service = {
"image": f"bozemanpass/backup:{deployment_command_context.cluster_context.cluster}",
"restart": "always",
"volumes": mounts,
"env_file": [relative_path_to(constants.config_file_name)], # brings in S3 creds + restic-password
"environment": {
"BACKUP_DEST": get_config_setting("backup-s3-bucket"),
"BACKUP_S3_ENDPOINT": get_config_setting("backup-s3-endpoint"),
"BACKUP_SCHEDULE": get_config_setting("backup-schedule", "0 3 * * *"),
"BACKUP_RETENTION": get_config_setting(
"backup-retention", "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"),
"BACKUP_PRE_HOOKS": ";".join(pre_hooks),
},
}
doc = {"services": {"backup": backup_service}}
out = destination_compose_dir.joinpath(f"{constants.compose_file_prefix}-backup.yml")
with open(out, "w") as f:
get_yaml().dump(doc, f)
```
Call it once, right after the pod loop:
```python
_maybe_write_backup_compose(parsed_spec, deployment_type, deployment_dir_path, destination_compose_dir)
```
Notes:
- **One mount per volume, `:ro`.** This matches the doc and lets restic see a clean per-volume tree. A
simpler variant is a single `../../data:/backup:ro` mount with restic `--exclude` patterns; either works.
- **`restic-password` and the S3 credentials** arrive via the shared `config.env` (`env_file`), which is
how `get_config_setting` values already reach containers β no new secret-plumbing path is introduced.
- The backup service joins the deployment's default network automatically, so `backup-command` hooks such
as `pg_dump -h db β¦` resolve the `db` service by name.
- The injected service appears in `stack manage β¦ ps` like any other container, which is what the
forthcoming `backup` subcommands will `exec` into.
## What this deliberately does *not* do yet
- It does not start a restic engine β the `bozemanpass/backup` image (its entrypoint, cron loop, hook
runner, and restore command) is the next step and lives in its own repo.
- It does not emit anything on the Kubernetes target; that path generates a K8up `Schedule` + annotations
and is a sibling to this function.
- It does not add the `stack manage β¦ backup` subcommands.
These are intentionally separable: this pass produces a deployment that *declares* its backup intent in a
single, inspectable compose file, which is the foundation the remaining pieces build on.
File created successfully at: /home/david/projects/bpi/stack/docs/backup-implementation.md (file state is current in your context β no need to Read it back)
Done. Two artifacts now in docs/:
docs/backup.md β the design (engine, ambient config, transparency model, restore, CLI shape).docs/backup-implementation.md β the implementation sketch you asked me to proceed with.The sketch pins the deploy-time injection pass to the real ingress machinery I traced, so it's executable as a plan rather than hand-waving:
Stack.get_backup_targets() next to get_http_proxy_targets() (stack.py:203), reading @stack backup-* from ruamel comment attributes the same way the port parser does.Spec.get_backup() next to get_http_proxy() (spec.py:140); merges additively across mixed-in specs for free._maybe_write_backup_compose() called after the pod-write loop (deployment_create.py:577), gated on the explicit backup master switch via get_config_setting, emitting one synthetic composefile-backup.yml.Two design decisions I made in the sketch worth your eye:
_fixup_pod_file (line 77) already lands every volume at <deployment-dir>/data/<vol>/, the backup service bind-mounts those paths :ro directly. This sidesteps the cross-compose-file named-volume declaration problem entirely β cleaner than trying to re-share the named volumes.restic-password and S3 creds ride in via the shared config.env (env_file), which is already how get_config_setting values reach containers β so no new secret-plumbing is invented, and the encryption-key warning from the design doc is the only special handling.The sketch is explicit about what it doesn't do yet (the bozemanpass/backup image, the manage β¦ backup subcommands, K8up emission) and why those are cleanly separable β this pass just produces a deployment that declares its backup intent in one inspectable file.
Natural next step when you want to continue: stand up the backup-stack repo (the restic container β entrypoint, cron loop, hook runner, restore command), since the injection pass references bozemanpass/backup: and nothing can actually run until that image exists. Want me to scaffold that repo's layout next, or pause here for you to review the two docs first?
Let me look at the empty repo and study how buildable-container stacks are structured in this ecosystem (the ingress stack uses upstream images, but backup needs a custom restic image, so I need the build convention).
echo "=== backup-stack contents ===" && ls -la /home/david/projects/bpi/backup-stack/ 2>/dev/null && echo "--- git remote ---" && git -C /home/david/projects/bpi/backup-stack remote -v 2>/dev/null; echo; echo "=== how stacks declare buildable containers (search data/ + build) ===" && grep -rn "containers\|repos\|build" src/stack/deploy/stack.py | head -20
{
"subagent_type": "Explore",
"prompt": "I'm in the BPI Stack codebase at /home/david/projects/bpi/stack (a Python CLI that builds and deploys container \"stacks\"). I need to understand the on-disk convention for a stack repository that BUILDS ITS OWN custom container image (as opposed to referencing an upstream image like nginx).\n\nBackground: a \"stack\" repo contains `stacks/<name>/stack.yml` (declares name/description/pods) and `<pod>/composefile.yml` (the compose services). I need to know how a stack additionally declares a container image that must be BUILT from a Dockerfile, and how `stack build containers` / `stack prepare` find and build it.\n\nPlease investigate and report concisely, with file paths + line numbers and short quoted snippets:\n\n1. How does a stack.yml (or composefile.yml) declare that it has containers to build? Look in src/stack/deploy/stack.py for how it reads a \"containers\" list, and src/stack/build/ (build_containers.py, build_util.py) for how containers are discovered and built.\n\n2. What is the directory/file convention for a buildable container? Is there a `container-build` dir, a `build.sh`, a Dockerfile location convention? Look in src/stack/data/ for any embedded container definitions or templates, and search the repo for example Dockerfiles or \"build.sh\" scripts.\n\n3. How does the build system map a container NAME (e.g. \"bozemanpass/backup\") to its build directory and Dockerfile? Is there a naming convention (e.g. cloud/name or a directory under a known path)?\n\n4. Is there any existing example IN THIS REPO of a stack that builds a custom container (look under src/stack/data/, and any test fixtures under tests/)? Show its layout.\n\n5. How does the `image:` field in a composefile relate to a built image β what tag/suffix convention is used (I saw \":stack\" suffixes being rewritten to a cluster name in deployment_create.py)? Explain the \":stack\" tag convention.\n\nReport findings only β do not write or modify any files."
}=== backup-stack contents ===
total 16
drwxr-xr-x 3 david david 4096 Jun 29 21:05 .
drwxr-xr-x 7 david david 4096 Jun 29 21:04 ..
drwxr-xr-x 8 david david 4096 Jun 29 21:05 .git
-rw-r--r-- 1 david david 79 Jun 29 21:05 README.md
--- git remote ---
origin git@github.com:bozemanpass/backup-stack.git (fetch)
origin git@github.com:bozemanpass/backup-stack.git (push)
=== how stacks declare buildable containers (search data/ + build) ===
src/stack/deploy/stack.py:24:import stack.repos.repo_util as repo_util
src/stack/deploy/stack.py:72: if "repository" not in pod:
src/stack/deploy/stack.py:73: pod["repository"] = self.get_repo_ref()
src/stack/deploy/stack.py:294: pod.get("repository", self.get_repo_ref()).split("@")[0],
src/stack/deploy/stack.py:355: pod.get("repository", stack.get_repo_ref()).split("@")[0],
src/stack/deploy/stack.py:375: pod.get("repository", parsed_stack.get_repo_ref()).split("@")[0],[
{
"type": "text",
"text": "Async agent launched successfully.\nagentId: a36bb9bcdab8719f9 (internal ID - do not mention to user. Use SendMessage with to: 'a36bb9bcdab8719f9', summary: '<5-10 word recap>' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.\nDo not duplicate this agent's work β avoid working with the same files or topics it is using.\noutput_file: /tmp/claude-1000/-home-david-projects-bpi-stack/83ff7993-2200-4857-b374-743227b368dd/tasks/a36bb9bcdab8719f9.output\nDo NOT Read or tail this file via the shell tool β it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification."
}
]cat /home/david/projects/bpi/backup-stack/README.md; echo "=== embedded data stacks with Dockerfiles ==="; find src/stack/data -iname "Dockerfile*" -o -iname "build.sh" 2>/dev/null | head; echo "=== any container-build dirs ==="; find src/stack/data -type d -name "*container*" 2>/dev/null | head; echo "=== stack.yml files in data ==="; find src/stack/data -name "stack.yml" | head
# backup-stack Backup components for the "stack" tool's docker deployment mode === embedded data stacks with Dockerfiles === src/stack/data/container-build/bozemanpass-webapp-base/build.sh src/stack/data/container-build/bozemanpass-nextjs-base/build.sh === any container-build dirs === src/stack/data/container-build === stack.yml files in data === src/stack/data/stacks/webapp-template/stack.yml
echo "=== container-build dir structure ==="; ls -la src/stack/data/container-build/bozemanpass-webapp-base/; echo "--- build.sh ---"; cat src/stack/data/container-build/bozemanpass-webapp-base/build.sh; echo; echo "=== webapp-template stack.yml ==="; cat src/stack/data/stacks/webapp-template/stack.yml; echo "=== webapp-template tree ==="; find src/stack/data/stacks/webapp-template -type f
=== container-build dir structure ===
total 24
drwxr-xr-x 3 david david 4096 Oct 28 2025 .
drwxr-xr-x 4 david david 4096 Apr 7 17:57 ..
-rw-r--r-- 1 david david 2440 Oct 28 2025 Containerfile
-rw-r--r-- 1 david david 265 Oct 28 2025 Containerfile.webapp
-rwxr-xr-x 1 david david 1092 Oct 28 2025 build.sh
drwxr-xr-x 2 david david 4096 Oct 28 2025 scripts
--- build.sh ---
#!/usr/bin/env bash
# Build bozemanpass/webapp-base
source ${STACK_CONTAINER_BASE_DIR}/build-base.sh
# See: https://stackoverflow.com/a/246128/1701505
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
STACK_CONTAINER_BUILD_WORK_DIR=${STACK_CONTAINER_BUILD_WORK_DIR:-$SCRIPT_DIR}
STACK_CONTAINER_BUILD_CONTAINERFILE=${STACK_CONTAINER_BUILD_CONTAINERFILE:-$SCRIPT_DIR/Containerfile}
STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/webapp-base:stack}
docker build -t $STACK_CONTAINER_BUILD_TAG ${build_command_args} -f $STACK_CONTAINER_BUILD_CONTAINERFILE $STACK_CONTAINER_BUILD_WORK_DIR
rc=$?
if [ $rc -ne 0 ]; then
echo "BUILD FAILED" 1>&2
exit $rc
fi
if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/webapp-base:stack" ]; then
cat <<EOF
#################################################################
Built host container for $STACK_CONTAINER_BUILD_WORK_DIR with tag:
$STACK_CONTAINER_BUILD_TAG
To test locally run:
stack webapp run --image $STACK_CONTAINER_BUILD_TAG --config-file /path/to/environment.env
EOF
fi
=== webapp-template stack.yml ===
version: "1.0"
name: internal-stack-webapp-template
description: "webapp template stack"
containers:
- bozemanpass/webapp-template-container
pods:
- webapp-template
=== webapp-template tree ===
src/stack/data/stacks/webapp-template/stack.yml
src/stack/data/stacks/webapp-template/README.mdThe convention is clear: stack.yml lists containers:, and each builds from container-build/<name-with-dashes>/ via a build.sh + Containerfile, tagged bozemanpass/<name>:stack. Let me see the Containerfile style and the build-base it sources while the agent confirms how external repos expose their container-build dirs.
{
"file_path": "/home/david/projects/bpi/stack/src/stack/data/container-build/bozemanpass-webapp-base/Containerfile"
}1 # Originally from: https://github.com/devcontainers/images/blob/main/src/javascript-node/.devcontainer/Dockerfile
2 # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
3 ARG VARIANT=20-bullseye-slim
4 FROM node:${VARIANT}
5
6 ARG USERNAME=node
7 ARG NPM_GLOBAL=/usr/local/share/npm-global
8
9 # Add NPM global to PATH.
10 ENV PATH=${NPM_GLOBAL}/bin:${PATH}
11 # Prevents npm from printing version warnings
12 ENV NPM_CONFIG_UPDATE_NOTIFIER=false
13
14 RUN \
15 # Configure global npm install location, use group to adapt to UID/GID changes
16 if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
17 && usermod -a -G npm ${USERNAME} \
18 && umask 0002 \
19 && mkdir -p ${NPM_GLOBAL} \
20 && touch /usr/local/etc/npmrc \
21 && chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
22 && chmod g+s ${NPM_GLOBAL} \
23 && npm config -g set prefix ${NPM_GLOBAL} \
24 && su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
25 # Install eslint
26 && su ${USERNAME} -c "umask 0002 && npm install -g eslint" \
27 # Install semver
28 && su ${USERNAME} -c "umask 0002 && npm install -g semver" \
29 # Install pnpm
30 && su ${USERNAME} -c "umask 0002 && npm install -g pnpm" \
31 # Install bun
32 && su ${USERNAME} -c "umask 0002 && npm install -g bun@1.1.x" \
33 && npm cache clean --force > /dev/null 2>&1
34
35 # [Optional] Uncomment this section to install additional OS packages.
36 RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
37 && apt-get -y install --no-install-recommends jq gettext-base git
38
39 # [Optional] Uncomment if you want to install an additional version of node using nvm
40 # ARG EXTRA_NODE_VERSION=10
41 # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
42
43 # We do this to get a yq binary from the published container, for the correct architecture we're building here
44 COPY --from=docker.io/mikefarah/yq:latest /usr/bin/yq /usr/local/bin/yq
45
46 COPY scripts /scripts
47
48 # [Optional] Uncomment if you want to install more global node modules
49 # RUN su node -c "npm install -g <your-package-list-here>"
50
51 RUN mkdir -p /config
52
53 # Install simple web server for now (use nginx perhaps later)
54 RUN yarn global add http-server
55
56 # Expose port for http
57 EXPOSE 80
58
59 # Default command sleeps forever so docker doesn't kill it
60 CMD ["/scripts/start-serving-app.sh"]
61 The build convention is taking shape. Let me wait for the research agent to confirm how an external fetched repo exposes its container-build dir to stack build/prepare, since that determines the exact layout and build.sh contract.
cat src/stack/data/container-build/bozemanpass-webapp-base/build.sh > /dev/null; echo "=== where is build-base.sh and STACK_CONTAINER_BASE_DIR set? ==="; grep -rn "STACK_CONTAINER_BASE_DIR\|build-base.sh\|container-build\|CONTAINER_BUILD" src/stack/build/*.py src/stack/constants.py | head -30
=== where is build-base.sh and STACK_CONTAINER_BASE_DIR set? ===
src/stack/build/build_webapp.py:51: container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
src/stack/build/build_webapp.py:81: container_build_env["STACK_CONTAINER_BUILD_WORK_DIR"] = os.path.abspath(source_repo)
src/stack/build/build_webapp.py:82: container_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = os.path.join(container_build_dir,
src/stack/build/build_webapp.py:89: container_build_env["STACK_CONTAINER_BUILD_TAG"] = tag
src/stack/build/build_containers.py:65: "STACK_CONTAINER_BASE_DIR": default_container_base_dir,
src/stack/build/build_containers.py:212: default_container_base_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build"){
"file_path": "/home/david/projects/bpi/stack/src/stack/build/build_containers.py",
"offset": 100,
"limit": 120
}100 if stack.name != "None" and stack_is_external(stack):
101 log_debug(f"Determined stack: {stack.name} is external")
102 # DBDB What is this code below doing?
103 # "build" is pulled from the container description yaml
104 # Presumably it means "the relative name of the build file"
105 if building_container.build:
106 # If the build script filename was provided, we use that
107 build_script_filename = Path(building_container.file_path).parent.joinpath(building_container.build)
108 build_dir = build_script_filename.parent
109 build_envs["STACK_BUILD_DIR"] = build_dir
110 else:
111 # If the build script filename is not explicitly provided, we try to infer it
112 # DBDB this code seems not to work because we use the bare stack name rather than a directory
113 # We go looking for a "containers" directory in the root of the container's repo.
114 container_build_script_dir = fs_path_for_repo(building_container.ref).joinpath(constants.stack_files_directory_name).joinpath(constants.containers_directory_name)
115 log_debug(f"Looking for build script in this directory: {container_build_script_dir}")
116 if os.path.exists(container_build_script_dir):
117 temp_build_dir = container_build_script_dir.joinpath(building_container.name.replace("/", "-"))
118 temp_build_script_filename = temp_build_dir.joinpath("build.sh")
119 # Now check if the container exists in the external stack.
120 log_debug(f"Looking for build script at: {temp_build_script_filename}")
121 if not temp_build_script_filename.exists():
122 # If not, revert to building an internal container
123 # DBDB Why?
124 container_build_script_dir = build_context.default_container_base_dir
125 build_dir = container_build_script_dir.joinpath(building_container.name.replace("/", "-"))
126 build_script_filename = build_dir.joinpath("build.sh")
127 build_envs["STACK_BUILD_DIR"] = build_dir
128
129 if not build_dir:
130 build_dir = build_context.default_container_base_dir.joinpath(building_container.name.replace("/", "-"))
131 build_script_filename = build_dir.joinpath("build.sh")
132
133 log_debug(f"Build script filename: {build_script_filename}")
134 log_debug(f"Build script filename: {build_dir}")
135
136 if os.path.exists(build_script_filename):
137 build_command = build_script_filename.as_posix()
138 else:
139 log_debug(f"No script file found: {build_script_filename}, using default build script")
140 if building_container.ref:
141 repo_full_path = fs_path_for_repo(building_container.ref)
142 else:
143 repo_full_path = stack.repo_path
144
145 if building_container.path:
146 repo_full_path = repo_full_path.joinpath(building_container.path)
147 repo_dir_or_build_dir = repo_full_path if repo_full_path and repo_full_path.exists() else build_dir
148 build_command = (
149 os.path.join(build_context.default_container_base_dir, "default-build.sh")
150 + f" {default_container_tag} {repo_dir_or_build_dir}"
151 )
152 build_envs["STACK_BUILD_DIR"] = repo_dir_or_build_dir
153
154 build_envs["STACK_IMAGE_NAME"] = building_container.name
155
156 build_envs["STACK_REPO_STACK_DIR"] = str(stack.repo_path) if stack.repo_path else ""
157 build_envs["STACK_REPO_CONTAINER_DIR"] = str(build_context.container.repo_path) if building_container.repo_path else build_envs["STACK_REPO_STACK_DIR"]
158 build_envs["STACK_REPO_SOURCE_DIR"] = str(fs_path_for_repo(building_container.ref)) if building_container.ref else build_envs["STACK_REPO_CONTAINER_DIR"]
159
160 if not opts.o.dry_run:
161 # No PATH at all causes failures with podman.
162 if "PATH" not in build_envs:
163 build_envs["PATH"] = os.environ["PATH"]
164 log_debug(f"Executing: {build_command} with environment: {build_envs}")
165
166 build_result = run_shell_command(build_command, env=build_envs, quiet=opts.o.quiet)
167
168 log_debug(f"Build command return code is: {build_result}")
169 if build_result != 0:
170 return False
171 else:
172 return True
173 else:
174 log_info("Skipped for dry run")
175 return True
176
177
178 def build_containers(parent_stack,
179 build_policy=get_config_setting("build-policy", BUILD_POLICIES[0]),
180 image_registry=get_config_setting("image-registry"),
181 publish_images=get_config_setting("publish-images", False),
182 include=None,
183 exclude=None,
184 extra_build_args=None,
185 git_ssh=get_config_setting("git-ssh", False),
186 git_pull=False,
187 dont_pull_repo_fs_paths=None,
188 target_arch=None,
189 dont_pull_images=False):
190 dev_root_path = get_dev_root_path()
191 required_stacks = parent_stack.get_required_stacks_paths()
192 if not dont_pull_repo_fs_paths:
193 dont_pull_repo_fs_paths = []
194
195 all_containers_in_scope = []
196 finished_containers = {}
197 for stack in required_stacks:
198 stack = get_parsed_stack_config(stack)
199 containers_in_scope = [c for c in get_containers_in_scope(stack) if include_exclude_check(c.name, include, exclude)]
200 all_containers_in_scope.extend(containers_in_scope)
201
202 log_info(f"Found {len(all_containers_in_scope)} containers in {len(required_stacks)} stacks: "
203 f"{', '.join([c.name for c in all_containers_in_scope])}", bold=True)
204
205 for stack in required_stacks:
206 stack = get_parsed_stack_config(stack)
207
208 if build_policy not in BUILD_POLICIES:
209 error_exit(f"{build_policy} is not one of {BUILD_POLICIES}")
210
211 # See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
212 default_container_base_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
213
214 log_debug("Dev Root is: {dev_root_path}")
215
216 if not os.path.isdir(dev_root_path):
217 log_debug("Dev root directory doesn't exist, creating")
218
219 if target_arch and target_arch != local_container_arch():grep -n "stack_files_directory_name\|containers_directory_name\|stacks_directory\|container_build" src/stack/constants.py
The external-repo build path has some self-acknowledged rough edges in the code (the DBDB ... seems not to work comments β your own notes). Rather than guess the layout, let me wait for the research agent, which is reconciling the stacks/ vs stack-files/containers/ vs container-build/ naming. I'll hold for it.
echo "Checking for any existing external buildable-container example to copy convention from:"; grep -rln "containers:" /home/david/projects/bpi/*/stacks/*/stack.yml 2>/dev/null; echo "--- machine repo stacks? ---"; find /home/david/projects/bpi/machine -name "stack.yml" 2>/dev/null | head; find /home/david/projects/bpi -maxdepth 3 -type d -name "containers" 2>/dev/null | grep -v "/stack/src" | head
Checking for any existing external buildable-container example to copy convention from: --- machine repo stacks? ---
No external examples to copy from in the sibling repos, so the agent's reconciliation is important. Let me wait for it to finish before scaffolding into possibly-wrong directories.
<tool_use_error>Blocked: sleep 30 followed by: echo done. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>
I shouldn't poll. The research agent is reconciling exactly the directory-convention ambiguity I hit (stacks/ vs stack-files/containers/ vs container-build/, and the buggy external-build resolution path). I'll wait for its result before scaffolding so I don't create files in the wrong layout β I'll continue automatically when it reports back.
While I wait, here's where things stand and the one open decision its findings will settle:
build.sh (sourcing build-base.sh) + Containerfile + scripts/, tagged bozemanpass/backup:stack; the stack is declared by a stack.yml with containers: [bozemanpass/backup] and pods: [backup], mirroring docker-ingress-stack.stack prepare --stack backup finds and builds the container. The code looks under stack-files/containers/<name> but carries your own DBDB β¦ seems not to work comments, so I want the agent's reconciliation (and whether to follow the proven ingress stacks/ layout) before committing paths.Once it reports, I'll scaffold:
- stacks/backup/stack.yml + backup/composefile.yml (the canonical backup service the injection pass mirrors),
- the container build dir with Containerfile (restic + cron) and scripts/ (entrypoint.sh cron setup, backup.sh hook-runner + restic backup + retention, restore.sh),
then point the implementation sketch's bozemanpass/backup: reference at it.
File: /home/david/projects/bpi/stack/src/stack/data/stacks/webapp-template/stack.yml
A stack.yml file declares containers in a containers section with either simple names or detailed objects:
containers:
- name: bozemanpass/act-runner
ref: bozemanpass/gitea-containers # optional: repo containing build info
path: ./act-runner # optional: path within repo
- bozemanpass/simple-container # simple form (just a name)
Source code reference: /home/david/projects/bpi/stack/src/stack/build/build_util.py:128 - the stack reads stack_config.get("containers", []), allowing both string names (lines 138-140) and objects with name/ref/path fields (line 142).
A stack repository organizes containers in a standard directory hierarchy. According to the documentation at /home/david/projects/bpi/stack/docs/stack-files.md:22-30, the container build directory must contain at least one of:
container.yml β metadata file defining the container's build configurationbuild.sh β custom build script (result must tag image as <name>:stack)Dockerfile or Containerfile β standard Dockerfile (built with default build.sh)Example layout in built-in containers (at /home/david/projects/bpi/stack/src/stack/data/container-build/):
container-build/
βββ bozemanpass-webapp-base/ # Name "bozemanpass/webapp-base" (/ β -)
β βββ Containerfile
β βββ build.sh
β βββ scripts/
βββ bozemanpass-nextjs-base/
β βββ Containerfile
β βββ build.sh
βββ default-build.sh # Used if no custom build.sh
βββ build-base.sh # Sourced by build scripts
Source: /home/david/projects/bpi/stack/src/stack/build/build_containers.py:117 and line 125
Naming convention: Container names are converted by replacing / with -:
bozemanpass/backup β bozemanpass-backup
bozemanpass/act-runner β bozemanpass-act-runner
Discovery logic (lines 114-127):
1. If container has explicit path in stack.yml, use repo:path/ for build directory
2. Else, look for stack-files/containers/{name.replace('/', '-')}/ within the container's repository
3. Else, look in built-in src/stack/data/container-build/{name.replace('/', '-')}/
The build system searches for build.sh in the final directory (line 118: build_dir.joinpath("build.sh")). If not found, it uses the default build script (line 149: default-build.sh).
File: /home/david/projects/bpi/stack/src/stack/data/stacks/webapp-template/stack.yml
name: internal-stack-webapp-template
description: "webapp template stack"
containers:
- bozemanpass/webapp-template-container
pods:
- webapp-template
Associated composefile at /home/david/projects/bpi/stack/src/stack/data/compose/composefile-webapp-template.yml:
services:
webapp:
image: bozemanpass/webapp-container:stack # Note :stack tag
Built-in base containers are defined at /home/david/projects/bpi/stack/src/stack/data/container-build/, with bozemanpass-webapp-base/build.sh (line 11) showing the expected tagging pattern: bozemanpass/webapp-base:stack.
:stack Tag ConventionPurpose: A local build-time tag indicating "this image was built locally for this stack."
Key locations:
- Build side (/home/david/projects/bpi/stack/src/stack/build/build_containers.py:86): All built images are tagged as {container_name}:stack
- Deployment side (/home/david/projects/bpi/stack/src/stack/deploy/deployment_create.py:535-536):
python
if image_name.endswith(":stack"):
service_info["image"] = image_name[:-5] + deployment_command_context.cluster_context.cluster
The :stack suffix is replaced with the cluster name (e.g., mainnet, testnet) at deployment time. This allows the same composefile to be deployed to different clusters with different image tags per cluster.
Alternative tags (/home/david/projects/bpi/stack/src/stack/deploy/images.py:30, 38):
- :local β legacy tag for locally-built images
- Images ending in :stack or :local trigger push operations when publishing remotely
Remote tagging convention (/home/david/projects/bpi/stack/docs/fetching-containers.md:20): Published remotely as {container_name}:{git_hash} (e.g., bozemanpass/gitea:ae0af8ea5b2de99a49add2b7f7b76dde62a8a617).
Summary: A stack that builds custom containers lists them in stack.yml's containers section with optional ref (repo) and path (directory within repo). The build system finds a build.sh, Dockerfile, or uses the default build script. Containers are tagged :stack locally, then the tag is replaced with the cluster name (:{cluster}) during deployment for multi-cluster deployments.
Now I have the authoritative convention. Let me read the spec doc it referenced to nail the exact external-repo layout before scaffolding.
1 ## stack.yml
2
3 The `stack.yml` file defines the structure and configuration of a deployment stack. It specifies the containers, pods,
4 and any pre/post-start commands required for the deployment.
5
6 ### Example
7
8 ```yaml
9 # The name of the stack.
10 name: gitea
11 # A brief description of the stack (optional).
12 description: "Gitea SCM and Actions"
13 # A list of containers to be used in the stack.
14 containers:
15 # The name of the container in the form `<organization>/<name>`. The pod's composefile.yml will need to use the
16 # same name for the image with the tag `stack`. For example: `image: bozemanpass/act-runner:stack`
17 - name: bozemanpass/act-runner
18 # An (optional) reference to the container's repository. The format is: [hostname/]organization/repo[@tag_or_branch]
19 # The hostname is optional. When omitted, github.com is assumed. The tag is also optional. If omitted,
20 # the main repo branch is used. If `ref` is omitted entirely, the current repo is assumed.
21 ref: bozemanpass/gitea-containers
22 # The relative path in the repo to the container build info. This directory must contain one (or more) of:
23 # - container.yml descriptor file (more info below)
24 # - build.sh build script
25 # The result of execution should be a local image tagged `<name>:stack`. The exact tag is available
26 # in the script build environment under ${STACK_DEFAULT_CONTAINER_IMAGE_TAG}.
27 # - Dockerfile
28 # The container will be built using the Dockerfile in this directory similar to:
29 # docker build -t ${STACK_DEFAULT_CONTAINER_IMAGE_TAG} .
30 path: ./act-runner
31 - name: bozemanpass/gitea
32 ref: bozemanpass/gitea-containers
33 path: ./gitea
34 # Pods are groups of containers that are deployed together. Each pod corresponds to one composefile.yml.
35 pods:
36 # The name of the pod.
37 - name: gitea
38 # The relative path in this repo to the directory containing the pod composefile.yml and other files.
39 path: ./gitea
40 # An (optional) command to run just _before_ the pod starts. The command is executed on the host, and the location
41 # is relative to the `path` specified above. The deployment directory will be set in the environment under
42 # ${STACK_DEPLOYMENT_DIR}, allowing a script to execute commands _inside_ the service containers with:
43 # stack manage --dir ${STACK_DEPLOYMENT_DIR} exec <service> <command>
44 pre_start_command: "run-this-first.sh"
45 # Similar to pre_start_command, but executed _after_ the pod starts.
46 post_start_command: "initialize-gitea.sh"
47 - name: act-runner
48 path: ./act-runner
49 pre_start_command: "pre_start.sh"
50 post_start_command: "post_start.sh"
51 ```
52
53 ## container.yml
54
55 The `container.yml` file defines the build configuration for individual containers within a stack. It specifies the container's name,
56 the repository reference, and the build script or command to be used for building the container image.
57
58 The build script path is relative to the `container.yml` file, not the target repo. In practice, this allows for build
59 scripts to be located in a separate repo from the container's source code, which is very useful for building customized
60 container images from repositories that are not under your control.
61
62 The following example from the `bozemanpass/gitea-containers` repo builds the `bozemanpass/act-runner` container directly from
63 the `gitea.com/gitea/act_runner` repo, for example.
64
65 ### Example
66 ```yaml
67 container:
68 # The name of the container in the form `<organization>/<name>`. The pod's composefile.yml will need to use the
69 # same name for the image with the tag `stack`. For example: `image: bozemanpass/act-runner:stack`
70 name: bozemanpass/act-runner
71 # An optional reference to the container's repository. The format is: [hostname/]organization/repo[@tag_or_branch]
72 # The hostname is optional. When omitted, github.com is assumed. The tag is also optional. If omitted,
73 # the main repo branch is used. If `ref` is omitted entirely, the current repo is assumed.
74 ref: gitea.com/gitea/act_runner
75 # Optional path to the container build script or command. This path is relative to the `container.yml` file.
76 # If no build script is provided, the default build command will be used.
77 build: ./build.sh
78 ```
79
80 ## container.lock
81
82 The `container.lock` file contains the git commit hash of the target repo. If not already present, the file is
83 automatically generated when the container is built. It can be committed to the repo to ensure the build will be
84 repeatable in the future, and when the repository is pulled by `stack` the appropriate commit will be checked out.
85
86 > Note: Even when `container.lock` is present, any local code changes will be included when building the container,
87 > since the hash is used only when the repository is cloned or pulled.
88
89 ## composefile.yml
90
91 The `composefile.yml` file defines the structure and configuration of a pod. It specifies the containers, volumes,
92 environment variables, and any other settings required for the pod's deployment.
93
94 It is compatible in syntax with `docker-compose.yml`.
95
96 ### Example
97 ```yaml
98 services:
99 runner:
100 image: bozemanpass/act-runner:stack
101 restart: always
102 privileged: true
103 environment:
104 - CONFIG_FILE=/config/act-runner-config.yml
105 - GITEA_INSTANCE_URL=http://${STACK_SVC_GITEA}:3000
106 volumes:
107 - act-runner-data:/data
108 - act-runner-config:/config:ro
109 ports:
110 - 8088
111
112 volumes:
113 act-runner-data:
114 act-runner-config:
115 ```
116
117 ### Environment Variables for Service Hostnames
118
119 When using `stack` to manage a pod, the service hostnames are automatically set in the environment variables for
120 each service. This allows for easy access to the hostnames of other services within the same deployment. The variables
121 are in the form `$STACK_SVC_<NAME>`, where `<NAME>` is the uppercase name of the service name as it appears in the
122 `composefile.yml`. In the example above, `$STACK_SVC_GITEA` would contain the hostname of the `gitea` service (located
123 in another pod in the same deployment), and `$STACK_SVC_RUNNER` would be set to the hostname of the `runner` service.That's the authoritative spec. Key facts that settle the layout:
stack.yml's containers: with name + optional ref/path pointing at a dir containing build.sh/Containerfile/container.yml. Using an explicit path sidesteps the buggy nameβdir inference (your DBDB notes).paths are relative to repo root (confirmed by how docker-ingress-stack resolves path: docker-ingress β repo-root docker-ingress/composefile.yml).bozemanpass/backup:stack, rewritten to the cluster name at deploy.I'll also make one design refinement while scaffolding: for the consistency hooks, the backup container will mount docker.sock and docker exec the dump command inside the target service β mirroring how nginx-proxy uses the sock, and matching K8up's "exec into the pod" model. That avoids baking every DB client into the image. Let me scaffold.
cd /home/david/projects/bpi/backup-stack && mkdir -p stacks/backup backup containers/backup/scripts && find . -not -path './.git/*' -type d | sort
. ./.git ./backup ./containers ./containers/backup ./containers/backup/scripts ./stacks ./stacks/backup Shell cwd was reset to /home/david/projects/bpi/stack
Now writing the repo files. Scaffold first, then I'll align the implementation doc with the two refinements (docker.sock hooks, canonical service-def living here).
name: backup
description: "Backup and restore of stack service data (restic to S3 object storage)"
# The backup container image (built from ./containers/backup in this repo).
containers:
- name: bozemanpass/backup
path: ./containers/backup
# A single-service pod. When backup is enabled, `stack deploy` injects read-only mounts
# of the application's data volumes into this pod's `backup` service.
pods:
- name: backup
path: ./backup
services:
backup:
image: bozemanpass/backup:stack
restart: always
volumes:
# The Docker socket lets the backup service run consistency hooks (e.g. pg_dump)
# *inside* the target service container, the same way the ingress proxy uses it.
- /var/run/docker.sock:/var/run/docker.sock:ro
# restic's local cache, to keep incremental backups fast.
- backup-restic-cache:/root/.cache/restic
#
# NOTE: read-only mounts of the application's data volumes are INJECTED here by
# `stack deploy` when backup is enabled. Each becomes `- <vol>:/backup/<vol>:ro`.
# See ../stack/docs/backup-implementation.md.
volumes:
backup-restic-cache:
#!/usr/bin/env bash
# Build bozemanpass/backup
set -euo pipefail
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# `stack build` provides the target tag (e.g. bozemanpass/backup:stack, or :<cluster>
# when publishing). Fall back to the local :stack tag for a manual build.
TAG="${STACK_DEFAULT_CONTAINER_IMAGE_TAG:-bozemanpass/backup:stack}"
docker build -t "$TAG" ${build_command_args:-} -f "$SCRIPT_DIR/Containerfile" "$SCRIPT_DIR"
File created successfully at: /home/david/projects/bpi/backup-stack/stacks/backup/stack.yml (file state is current in your context β no need to Read it back)
FROM alpine:3.20 # restic - the backup engine (mandatory client-side encryption, dedup, S3-native) # docker-cli - to exec consistency hooks (pg_dump, etc.) inside target containers # bash - script runtime # tzdata - correct cron scheduling in the configured timezone # ca-certificates - TLS to the object store RUN apk add --no-cache restic bash docker-cli tzdata ca-certificates COPY scripts /scripts RUN chmod +x /scripts/*.sh WORKDIR /backup # Default mode is the scheduler. Other modes: backup | restore | prune | check. ENTRYPOINT ["/scripts/docker-entrypoint.sh"] CMD ["schedule"]
File created successfully at: /home/david/projects/bpi/backup-stack/backup/composefile.yml (file state is current in your context β no need to Read it back)
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/build.sh (file state is current in your context β no need to Read it back)
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/Containerfile (file state is current in your context β no need to Read it back)
#!/usr/bin/env bash
# Shared restic environment setup, sourced by the other scripts.
set -euo pipefail
# Build RESTIC_REPOSITORY from the S3 settings if it was not supplied directly.
if [ -z "${RESTIC_REPOSITORY:-}" ]; then
if [ -n "${BACKUP_S3_ENDPOINT:-}" ] && [ -n "${BACKUP_S3_BUCKET:-}" ]; then
export RESTIC_REPOSITORY="s3:https://${BACKUP_S3_ENDPOINT}/${BACKUP_S3_BUCKET}"
else
echo "backup: no RESTIC_REPOSITORY (or BACKUP_S3_ENDPOINT + BACKUP_S3_BUCKET) configured" >&2
exit 1
fi
fi
# The encryption key is mandatory: without it the repository cannot be read back.
if [ -z "${RESTIC_PASSWORD:-}" ] && [ -z "${RESTIC_PASSWORD_FILE:-}" ]; then
echo "backup: RESTIC_PASSWORD is not set - refusing to run without an encryption key" >&2
exit 1
fi
# Create the repository on first use (idempotent).
ensure_repo() {
if ! restic cat config >/dev/null 2>&1; then
echo "backup: initializing restic repository at ${RESTIC_REPOSITORY}"
restic init
fi
}
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/scripts/lib.sh (file state is current in your context β no need to Read it back)
#!/usr/bin/env bash
set -euo pipefail
source /scripts/lib.sh
mode="${1:-schedule}"
case "$mode" in
schedule)
ensure_repo
schedule="${BACKUP_SCHEDULE:-0 3 * * *}"
echo "backup: scheduling '${schedule}' -> /scripts/backup.sh"
# Single cron entry; send job output to PID 1's stdout so it shows in container logs.
echo "${schedule} /scripts/backup.sh >> /proc/1/fd/1 2>&1" > /etc/crontabs/root
exec crond -f -l 8
;;
backup) exec /scripts/backup.sh ;;
restore) shift; exec /scripts/restore.sh "$@" ;;
prune) exec /scripts/prune.sh ;;
check) ensure_repo; exec restic check ;;
*)
echo "backup: unknown mode '$mode' (expected: schedule|backup|restore|prune|check)" >&2
exit 2
;;
esac
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/scripts/docker-entrypoint.sh (file state is current in your context β no need to Read it back)
#!/usr/bin/env bash
set -euo pipefail
source /scripts/lib.sh
ensure_repo
# 1. Run consistency hooks (logical dumps) into the backup tree.
/scripts/run-hooks.sh
# 2. Back up everything mounted under /backup: the read-only data volumes plus any dumps.
echo "backup: starting restic backup of /backup"
restic backup --host "${STACK_DEPLOYMENT:-stack}" /backup
# 3. Apply the retention policy.
/scripts/prune.sh
echo "backup: complete"
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/scripts/backup.sh (file state is current in your context β no need to Read it back)
#!/usr/bin/env bash
# Run per-service consistency-dump commands *inside* their containers (via the Docker
# socket) and write the output under /backup/_dumps so restic captures a consistent
# logical backup alongside the file-level volume data.
#
# BACKUP_PRE_HOOKS format: "service:command:ext;service2:command2:ext2"
# e.g. "db:pg_dump -U postgres -d todos:sql"
set -euo pipefail
hooks="${BACKUP_PRE_HOOKS:-}"
[ -z "$hooks" ] && exit 0
dump_dir="/backup/_dumps"
mkdir -p "$dump_dir"
# Resolve the compose project of THIS container, so hooks only exec into sibling services
# in the same deployment.
self_id="$(grep -o -m1 '[0-9a-f]\{64\}' /proc/self/cgroup || true)"
project="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' "$self_id" 2>/dev/null || true)"
IFS=';' read -ra entries <<< "$hooks"
for entry in "${entries[@]}"; do
[ -z "$entry" ] && continue
svc="${entry%%:*}"; rest="${entry#*:}"
cmd="${rest%:*}"; ext="${rest##*:}"
cid="$(docker ps -q \
--filter "label=com.docker.compose.project=${project}" \
--filter "label=com.docker.compose.service=${svc}" | head -n1)"
if [ -z "$cid" ]; then
echo "backup: hook target service '${svc}' not found - skipping" >&2
continue
fi
echo "backup: dumping '${svc}' (${cmd})"
docker exec "$cid" sh -c "$cmd" > "${dump_dir}/${svc}.${ext}"
done
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/scripts/run-hooks.sh (file state is current in your context β no need to Read it back)
#!/usr/bin/env bash
set -euo pipefail
source /scripts/lib.sh
# Word-splitting of $retention is intentional (the flags are passed through to restic).
retention="${BACKUP_RETENTION:---keep-daily 7 --keep-weekly 4 --keep-monthly 6}"
echo "backup: applying retention (${retention})"
# shellcheck disable=SC2086
restic forget --prune $retention
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/scripts/prune.sh (file state is current in your context β no need to Read it back)
#!/usr/bin/env bash
# Restore a snapshot into the volume tree.
#
# Usage: restore.sh [snapshot-id] (default: latest)
#
# Restore is run as a distinct mode: the full application stack is stopped first and the
# data volumes are mounted READ-WRITE under /backup, so restic writes the chosen epoch's
# data back in place. The full stack is then started again. See ../stack/docs/backup.md.
set -euo pipefail
source /scripts/lib.sh
ensure_repo
snapshot="${1:-latest}"
echo "backup: restoring snapshot '${snapshot}' into /backup"
restic restore "$snapshot" --target /
# NOTE: logical dumps land back under /backup/_dumps as files. Replaying them into a live
# database (e.g. pg_restore) is a deliberate follow-up, not yet automated - see the
# "Restore" and open-questions sections of docs/backup.md.
echo "backup: restore complete"
File created successfully at: /home/david/projects/bpi/backup-stack/containers/backup/scripts/restore.sh (file state is current in your context β no need to Read it back)
# backup-stack Backup and restore of persistent service data for the [`stack`](https://github.com/bozemanpass/stack) tool's Docker deployment mode. > **Status: initial scaffold β not yet functional.** The container scripts are a starting point and have > not been run end-to-end. See the design in the `stack` repo: `docs/backup.md` and > `docs/backup-implementation.md`. ## What this provides `bozemanpass/backup` β a small Alpine image bundling: - [restic](https://restic.net) β the backup engine: mandatory client-side encryption, deduplication, and native S3 support, so commodity object storage can be used safely; - a cron scheduler; - the Docker CLI β used to run application-consistency hooks (e.g. `pg_dump`) *inside* the target service container, the same way the ingress proxy uses the Docker socket. On Kubernetes the equivalent role is played by [K8up](https://k8up.io) (also restic-based), so the two targets produce interchangeable repositories. This repo covers the Docker case only. ## Layout | Path | Purpose | |------|---------| | `stacks/backup/stack.yml` | Stack definition β declares the container and the pod. | | `backup/composefile.yml` | The canonical `backup` service. `stack deploy` injects read-only mounts of the application's data volumes here when backup is enabled. | | `containers/backup/` | The `bozemanpass/backup` image: `Containerfile`, `build.sh`, and `scripts/`. | ## Container modes The image entrypoint takes a mode argument (default `schedule`): | Mode | Action | |------|--------| | `schedule` | Install a cron entry (`BACKUP_SCHEDULE`) that runs `backup` periodically. | | `backup` | Run hooks, then `restic backup` of `/backup`, then apply retention. | | `restore [snapshot]` | Restore a snapshot into the (rw-mounted) volumes. Default `latest`. | | `prune` | Apply the retention policy (`restic forget --prune`). | | `check` | Verify repository integrity. | ## Configuration Supplied by `stack` from the deployment environment (see `docs/backup.md` for the profile keys): | Variable | Purpose | |----------|---------| | `BACKUP_S3_ENDPOINT`, `BACKUP_S3_BUCKET` | Object store location (or set `RESTIC_REPOSITORY` directly). | | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Object store credentials. | | `RESTIC_PASSWORD` | **Encryption key** β mandatory. Without it the repository is unreadable. | | `BACKUP_SCHEDULE` | Cron schedule (default `0 3 * * *`). | | `BACKUP_RETENTION` | `forget`/`prune` flags (default `--keep-daily 7 --keep-weekly 4 --keep-monthly 6`). | | `BACKUP_PRE_HOOKS` | `service:command:ext;β¦` consistency dumps, generated from `@stack backup-command` annotations. | ## Build & use (intended) ```bash stack fetch repo bozemanpass/backup-stack stack prepare --stack backup # builds bozemanpass/backup:stack # Backup is then enabled per-deployment via the `backup` config switch; see docs/backup.md. ```
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
# backup-stack Backup and restore of persistent service data for the [`stack`](https://github.com/bozemanpass/stack) tool's Docker deployment mode. > **Status: initial scaffold β not yet functional.** The container scripts are a starting point and have > not been run end-to-end. See the design in the `stack` repo: `docs/backup.md` and > `docs/backup-implementation.md`. ## What this provides `bozemanpass/backup` β a small Alpine image bundling: - [restic](https://restic.net) β the backup engine: mandatory client-side encryption, deduplication, and native S3 support, so commodity object storage can be used safely; - a cron scheduler; - the Docker CLI β used to run application-consistency hooks (e.g. `pg_dump`) *inside* the target service container, the same way the ingress proxy uses the Docker socket. On Kubernetes the equivalent role is played by [K8up](https://k8up.io) (also restic-based), so the two targets produce interchangeable repositories. This repo covers the Docker case only. ## Layout | Path | Purpose | |------|---------| | `stacks/backup/stack.yml` | Stack definition β declares the container and the pod. | | `backup/composefile.yml` | The canonical `backup` service. `stack deploy` injects read-only mounts of the application's data volumes here when backup is enabled. | | `containers/backup/` | The `bozemanpass/backup` image: `Containerfile`, `build.sh`, and `scripts/`. | ## Container modes The image entrypoint takes a mode argument (default `schedule`): | Mode | Action | |------|--------| | `schedule` | Install a cron entry (`BACKUP_SCHEDULE`) that runs `backup` periodically. | | `backup` | Run hooks, then `restic backup` of `/backup`, then apply retention. | | `restore [snapshot]` | Restore a snapshot into the (rw-mounted) volumes. Default `latest`. | | `prune` | Apply the retention policy (`restic forget --prune`). | | `check` | Verify repository integrity. | ## Configuration Supplied by `stack` from the deployment environment (see `docs/backup.md` for the profile keys): | Variable | Purpose | |----------|---------| | `BACKUP_S3_ENDPOINT`, `BACKUP_S3_BUCKET` | Object store location (or set `RESTIC_REPOSITORY` directly). | | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Object store credentials. | | `RESTIC_PASSWORD` | **Encryption key** β mandatory. Without it the repository is unreadable. | | `BACKUP_SCHEDULE` | Cron schedule (default `0 3 * * *`). | | `BACKUP_RETENTION` | `forget`/`prune` flags (default `--keep-daily 7 --keep-weekly 4 --keep-monthly 6`). | | `BACKUP_PRE_HOOKS` | `service:command:ext;β¦` consistency dumps, generated from `@stack backup-command` annotations. | ## Build & use (intended) ```bash stack fetch repo bozemanpass/backup-stack stack prepare --stack backup # builds bozemanpass/backup:stack # Backup is then enabled per-deployment via the `backup` config switch; see docs/backup.md. ```
The file /home/david/projects/bpi/backup-stack/README.md has been updated successfully. (file state is current in your context β no need to Read it back)
cd /home/david/projects/bpi/backup-stack && chmod +x containers/backup/build.sh containers/backup/scripts/*.sh && echo "=== final tree ===" && find . -not -path './.git/*' -type f | sort && echo && echo "=== bash syntax check on scripts ===" && for f in containers/backup/build.sh containers/backup/scripts/*.sh; do bash -n "$f" && echo "ok: $f" || echo "SYNTAX ERROR: $f"; done
=== final tree === ./README.md ./backup/composefile.yml ./containers/backup/Containerfile ./containers/backup/build.sh ./containers/backup/scripts/backup.sh ./containers/backup/scripts/docker-entrypoint.sh ./containers/backup/scripts/lib.sh ./containers/backup/scripts/prune.sh ./containers/backup/scripts/restore.sh ./containers/backup/scripts/run-hooks.sh ./stacks/backup/stack.yml === bash syntax check on scripts === ok: containers/backup/build.sh ok: containers/backup/scripts/backup.sh ok: containers/backup/scripts/docker-entrypoint.sh ok: containers/backup/scripts/lib.sh ok: containers/backup/scripts/prune.sh ok: containers/backup/scripts/restore.sh ok: containers/backup/scripts/run-hooks.sh
All scripts pass syntax checks. Now let me align the implementation doc with the two refinements the scaffold introduced (docker.sock-based hooks; the canonical service now lives in backup-stack and gets augmented rather than fabricated).
{
"file_path": "/home/david/projects/bpi/stack/docs/backup-implementation.md",
"offset": 85,
"limit": 75
}85 # deploy/spec.py
86 def get_backup(self):
87 return self.obj.get(constants.backup_key, {})
88 ```
89
90 Because `backup` is a plain dict it merges additively across mixed-in specs with no special handling, like
91 the other spec sections.
92
93 ## 3. Inject the backup service at deploy time β `deploy/deployment_create.py`
94
95 This is the core of the pass. After the per-pod compose files are written (the loop ending at
96 `deployment_create.py:577`), and only for the `compose` target with the master switch enabled, write **one
97 synthetic compose file** containing the backup service.
98
99 The backup service mounts the deployment's data **read-only**. On Docker every named volume is already a
100 bind mount under `<deployment-dir>/data/<volume-name>/` (`_fixup_pod_file`, line 77), so the backup service
101 can bind-mount those host paths directly β sidestepping cross-compose-file named-volume declaration
102 entirely.
103
104 ```python
105 # deploy/deployment_create.py β after the `for pod in pods:` loop (~line 577)
106
107 from stack.config.util import get_config_setting
108
109 def _maybe_write_backup_compose(parsed_spec, deployment_type, deployment_dir_path, destination_compose_dir):
110 if deployment_type != "compose":
111 return # k8s path emits K8up resources instead (separate step)
112 if not get_config_setting("backup", False): # explicit master switch (env β profile β default false)
113 return
114
115 backup_cfg = parsed_spec.get_backup()
116 exclude = set(backup_cfg.get("exclude", []))
117
118 # All read-write named volumes, minus author-excluded ones.
119 volumes = parsed_spec.get_volumes() # {name: host_path_or_None}
120 backed_up = [v for v in volumes if v not in exclude]
121
122 mounts = [f"../../data/{v}:/backup/{v}:ro" for v in backed_up] # relative to the compose dir
123
124 # Per-service consistent-dump hooks (run inside the backup container, reaching services by name
125 # over the shared compose network).
126 pre_hooks = []
127 for svc, c in backup_cfg.get("commands", {}).items():
128 ext = c.get("file_extension", "dump")
129 pre_hooks.append(f"{svc}:{c['command']}:{ext}") # parsed by the backup image entrypoint
130
131 backup_service = {
132 "image": f"bozemanpass/backup:{deployment_command_context.cluster_context.cluster}",
133 "restart": "always",
134 "volumes": mounts,
135 "env_file": [relative_path_to(constants.config_file_name)], # brings in S3 creds + restic-password
136 "environment": {
137 "BACKUP_DEST": get_config_setting("backup-s3-bucket"),
138 "BACKUP_S3_ENDPOINT": get_config_setting("backup-s3-endpoint"),
139 "BACKUP_SCHEDULE": get_config_setting("backup-schedule", "0 3 * * *"),
140 "BACKUP_RETENTION": get_config_setting(
141 "backup-retention", "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"),
142 "BACKUP_PRE_HOOKS": ";".join(pre_hooks),
143 },
144 }
145
146 doc = {"services": {"backup": backup_service}}
147 out = destination_compose_dir.joinpath(f"{constants.compose_file_prefix}-backup.yml")
148 with open(out, "w") as f:
149 get_yaml().dump(doc, f)
150 ```
151
152 Call it once, right after the pod loop:
153
154 ```python
155 _maybe_write_backup_compose(parsed_spec, deployment_type, deployment_dir_path, destination_compose_dir)
156 ```
157
158 Notes:
159 The scaffold made Model B (augment the canonical service, like ingress augments nginx) cleaner than the original "fabricate a synthetic file" sketch β and it avoids duplicating the service definition in both Python and the repo. Let me update section 3 to match.
## 3. Inject the backup service at deploy time β `deploy/deployment_create.py`
This is the core of the pass. After the per-pod compose files are written (the loop ending at
`deployment_create.py:577`), and only for the `compose` target with the master switch enabled, write **one
synthetic compose file** containing the backup service.
The backup service mounts the deployment's data **read-only**. On Docker every named volume is already a
bind mount under `<deployment-dir>/data/<volume-name>/` (`_fixup_pod_file`, line 77), so the backup service
can bind-mount those host paths directly β sidestepping cross-compose-file named-volume declaration
entirely.
```python
# deploy/deployment_create.py β after the `for pod in pods:` loop (~line 577)
from stack.config.util import get_config_setting
def _maybe_write_backup_compose(parsed_spec, deployment_type, deployment_dir_path, destination_compose_dir):
if deployment_type != "compose":
return # k8s path emits K8up resources instead (separate step)
if not get_config_setting("backup", False): # explicit master switch (env β profile β default false)
return
backup_cfg = parsed_spec.get_backup()
exclude = set(backup_cfg.get("exclude", []))
# All read-write named volumes, minus author-excluded ones.
volumes = parsed_spec.get_volumes() # {name: host_path_or_None}
backed_up = [v for v in volumes if v not in exclude]
mounts = [f"../../data/{v}:/backup/{v}:ro" for v in backed_up] # relative to the compose dir
# Per-service consistent-dump hooks (run inside the backup container, reaching services by name
# over the shared compose network).
pre_hooks = []
for svc, c in backup_cfg.get("commands", {}).items():
ext = c.get("file_extension", "dump")
pre_hooks.append(f"{svc}:{c['command']}:{ext}") # parsed by the backup image entrypoint
backup_service = {
"image": f"bozemanpass/backup:{deployment_command_context.cluster_context.cluster}",
"restart": "always",
"volumes": mounts,
"env_file": [relative_path_to(constants.config_file_name)], # brings in S3 creds + restic-password
"environment": {
"BACKUP_DEST": get_config_setting("backup-s3-bucket"),
"BACKUP_S3_ENDPOINT": get_config_setting("backup-s3-endpoint"),
"BACKUP_SCHEDULE": get_config_setting("backup-schedule", "0 3 * * *"),
"BACKUP_RETENTION": get_config_setting(
"backup-retention", "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"),
"BACKUP_PRE_HOOKS": ";".join(pre_hooks),
},
}
doc = {"services": {"backup": backup_service}}
out = destination_compose_dir.joinpath(f"{constants.compose_file_prefix}-backup.yml")
with open(out, "w") as f:
get_yaml().dump(doc, f)
```
Call it once, right after the pod loop:
```python
_maybe_write_backup_compose(parsed_spec, deployment_type, deployment_dir_path, destination_compose_dir)
```
Notes:## 3. Augment the backup service at deploy time β `deploy/deployment_create.py`
This is the core of the pass, and it follows the ingress model exactly: the **backup service is defined in
the `backup-stack` repo** (`backup/composefile.yml`), and the deploy step only *augments* it β appending the
read-only data-volume mounts and setting the backup environment β just as ingress augments the existing
`nginx` service with `VIRTUAL_HOST_MULTIPORTS`. We do **not** fabricate the service in Python, so its
definition (image, Docker-socket mount, restic cache volume, entrypoint) lives in one place.
Enabling the `backup` master switch causes the `backup-stack` pod to be included in the deployment
automatically (resolved like an implicit extra spec, so the operator does not pass it by hand). The augment
then runs inside the **existing** per-pod service loop, right next to the `VIRTUAL_HOST_MULTIPORTS`
injection (`deployment_create.py:550-574`):
```python
# deploy/deployment_create.py β within the `for service_name in services:` loop,
# alongside the VIRTUAL_HOST_MULTIPORTS block (~line 550)
from stack.config.util import get_config_setting
if get_config_setting("backup", False) and service_name == constants.backup_service_name:
backup_cfg = parsed_spec.get_backup()
exclude = set(backup_cfg.get("exclude", []))
# Append a read-only mount for every (non-excluded) data volume to the service the
# backup-stack composefile already defines. On Docker each named volume is a bind mount
# under <deployment-dir>/data/<name>/ (`_fixup_pod_file`, line 77), so we mount those host
# paths directly and avoid re-declaring named volumes across compose files.
mounts = service_info.setdefault("volumes", [])
for v in parsed_spec.get_volumes():
if v not in exclude:
mounts.append(f"../../data/{v}:/backup/{v}:ro")
# Consistency-dump hooks: "service:command:ext;...". Executed inside the target container
# via the Docker socket the backup service already mounts (mirrors K8up's backupcommand).
pre_hooks = [
f"{svc}:{c['command']}:{c.get('file_extension', 'dump')}"
for svc, c in backup_cfg.get("commands", {}).items()
]
svc_env = service_info.get("environment", {})
add_env_var("BACKUP_S3_ENDPOINT", get_config_setting("backup-s3-endpoint"), svc_env)
add_env_var("BACKUP_S3_BUCKET", get_config_setting("backup-s3-bucket"), svc_env)
add_env_var("BACKUP_SCHEDULE", get_config_setting("backup-schedule", "0 3 * * *"), svc_env)
add_env_var("BACKUP_RETENTION",
get_config_setting("backup-retention",
"--keep-daily 7 --keep-weekly 4 --keep-monthly 6"), svc_env)
add_env_var("BACKUP_PRE_HOOKS", ";".join(pre_hooks), svc_env)
service_info["environment"] = svc_env
```
`constants.backup_service_name` is `"backup"` β the service name in `backup-stack/backup/composefile.yml`.
The `restic-password` and S3 credentials are *not* set here: they arrive via the shared `config.env`
(`env_file`) already injected into every service at `deployment_create.py:538-548`, so no new secret path
is introduced.
Notes:The file /home/david/projects/bpi/stack/docs/backup-implementation.md has been updated successfully. (file state is current in your context β no need to Read it back)
{
"file_path": "/home/david/projects/bpi/stack/docs/backup-implementation.md",
"offset": 151,
"limit": 35
}151 simpler variant is a single `../../data:/backup:ro` mount with restic `--exclude` patterns; either works. 152 - **`restic-password` and the S3 credentials** arrive via the shared `config.env` (`env_file`), which is 153 how `get_config_setting` values already reach containers β no new secret-plumbing path is introduced. 154 - The backup service joins the deployment's default network automatically, so `backup-command` hooks such 155 as `pg_dump -h db β¦` resolve the `db` service by name. 156 - The injected service appears in `stack manage β¦ ps` like any other container, which is what the 157 forthcoming `backup` subcommands will `exec` into. 158 159 ## What this deliberately does *not* do yet 160 161 - It does not start a restic engine β the `bozemanpass/backup` image (its entrypoint, cron loop, hook 162 runner, and restore command) is the next step and lives in its own repo. 163 - It does not emit anything on the Kubernetes target; that path generates a K8up `Schedule` + annotations 164 and is a sibling to this function. 165 - It does not add the `stack manage β¦ backup` subcommands. 166 167 These are intentionally separable: this pass produces a deployment that *declares* its backup intent in a 168 single, inspectable compose file, which is the foundation the remaining pieces build on. 169
Two notes now contradict the refinements (network hooks β docker.sock; and the image now exists as a scaffold). Fixing those.
- **`restic-password` and the S3 credentials** arrive via the shared `config.env` (`env_file`), which is how `get_config_setting` values already reach containers β no new secret-plumbing path is introduced. - The backup service joins the deployment's default network automatically, so `backup-command` hooks such as `pg_dump -h db β¦` resolve the `db` service by name. - The injected service appears in `stack manage β¦ ps` like any other container, which is what the forthcoming `backup` subcommands will `exec` into.
- **`restic-password` and the S3 credentials** arrive via the shared `config.env` (`env_file`), which is how `get_config_setting` values already reach containers β no new secret-plumbing path is introduced. - **Consistency hooks run via the Docker socket**, which the `backup` service mounts in `backup-stack/backup/composefile.yml`. The hook runner `docker exec`s the dump command inside the target service container (resolved by compose label), mirroring K8up's exec-into-the-pod model β so no database clients need to be baked into the backup image. - The backup service appears in `stack manage β¦ ps` like any other container, which is what the forthcoming `backup` subcommands will `exec` into.
The file /home/david/projects/bpi/stack/docs/backup-implementation.md has been updated successfully. (file state is current in your context β no need to Read it back)
## What this deliberately does *not* do yet - It does not start a restic engine β the `bozemanpass/backup` image (its entrypoint, cron loop, hook runner, and restore command) is the next step and lives in its own repo. - It does not emit anything on the Kubernetes target; that path generates a K8up `Schedule` + annotations and is a sibling to this function. - It does not add the `stack manage β¦ backup` subcommands. These are intentionally separable: this pass produces a deployment that *declares* its backup intent in a single, inspectable compose file, which is the foundation the remaining pieces build on.
## What this deliberately does *not* do yet - The `bozemanpass/backup` image exists as an **initial scaffold** in the `backup-stack` repo (its `Containerfile`, `build.sh`, and `scripts/` for entrypoint, cron, hook runner, and restore) but has not been run end-to-end and is not yet wired into a build. - The deploy-time augment above is **not yet implemented in code** β this document is still the spec for it. - It does not emit anything on the Kubernetes target; that path generates a K8up `Schedule` + annotations and is a sibling to this function. - It does not add the `stack manage β¦ backup` subcommands. These are intentionally separable: this pass produces a deployment whose `backup` service *declares* its backup intent (its `:ro` data mounts and env) in the generated compose, which is the foundation the remaining pieces build on.
The file /home/david/projects/bpi/stack/docs/backup-implementation.md has been updated successfully. (file state is current in your context β no need to Read it back)
Done. The backup-stack repo is scaffolded and the stack-tool design docs are aligned with it.
../backup-stackbackup-stack/
βββ README.md # updated: status, layout, modes, config
βββ stacks/backup/stack.yml # declares container + pod
βββ backup/composefile.yml # canonical `backup` service (docker.sock + restic cache)
βββ containers/backup/
βββ Containerfile # alpine + restic + docker-cli + cron
βββ build.sh # tags bozemanpass/backup:stack
βββ scripts/
βββ docker-entrypoint.sh # modes: schedule|backup|restore|prune|check
βββ lib.sh # restic repo/env setup, refuses to run w/o encryption key
βββ backup.sh # hooks β restic backup β retention
βββ run-hooks.sh # docker exec consistency dumps into target containers
βββ prune.sh # restic forget --prune
βββ restore.sh # restic restore into rw volumes
All seven shell scripts pass bash -n syntax checks. I followed the authoritative docs/stack-files.md convention and used an explicit path: ./containers/backup in stack.yml to sidestep the nameβdir inference your DBDB comments flag as unreliable.
docker exec-ing the dump command inside the target container β mirroring K8up's exec-into-pod model. This keeps DB clients out of the backup image.backup service now lives in backup-stack/backup/composefile.yml; the deploy-time pass only appends :ro mounts + env to it β exactly how ingress augments the nginx service. I updated docs/backup-implementation.md Β§3 accordingly (and fixed the stale network-hook and "image doesn't exist yet" notes).The highest-value thing to verify first is the build wiring: stack fetch repo bozemanpass/backup-stack β stack prepare --stack backup actually locating containers/backup/build.sh and producing bozemanpass/backup:stack. That's the one piece dependent on the external-repo build path you've noted is shaky, and everything else assumes it works. Want me to trace that resolution against build_containers.py to confirm the explicit-path form hits a working code path before you try it β or commit the scaffold first?