This is a solid idea, and the good news is the codebase is already about 70% of the way there structurally. Here's my assessment after reviewing docs/webapp.md and the build machinery.
What exists today, and why it helps
The webapp path is already a "wrapping scheme" in all but name — it's just hardcoded and lives off to the side:
- Base image recipes live in-tree at
src/stack/data/container-build/bozemanpass-webapp-base/andbozemanpass-nextjs-base/. Each has a baseContainerfileplus aContainerfile.webappthat does the two-stage "copy app repo in, build, produce runtime image" dance. - The seam you need already exists:
build_webapp.pydrives the genericprocess_container()by overriding three env vars —STACK_CONTAINER_BUILD_CONTAINERFILE,STACK_CONTAINER_BUILD_WORK_DIR(the app repo), andSTACK_CONTAINER_BUILD_TAG. That env contract is the wrapper interface; it just isn't formalized. - External container builds are already supported:
process_container()looks forstack-files/containers/<name>/build.shin fetched repos, and repos are fetched/discovered by convention underSTACK_REPO_BASE_DIR. So the "definitions live in their own repos" part has an existing pattern to copy.
What's missing is exactly what you identified: webapp-ness is not a first-class concept in stack.yml (it's a separate webapp build command family with a special-cased webapp-template stack and some post-hoc YAML rewriting in deploy_webapp.py), and there's no way to enumerate or add wrapping schemes.
Proposed shape
1. Make the wrapper an explicit attribute on a container entry in stack.yml. Something like:
containers:
- name: myorg/my-site
ref: github.com/myorg/my-site
wrapper: static-content # resolved by name among fetched wrapper repos
# or fully qualified: wrapper: github.com/bozemanpass/static-content-wrapper
This slots cleanly into the decision tree in process_container() as a fourth branch alongside "explicit build script / inferred build.sh / repo Dockerfile fallback": if wrapper is present, resolve the wrapper, then reuse the existing env-var override mechanism to build the app image from the wrapper's Containerfile with the app repo as build context. No new build engine needed.
2. Define a wrapper repo as a small, declarative artifact. A git repo (or directory in one) containing a manifest — say wrapper.yml — plus the build materials:
name: static-content
description: Serve static HTML with nginx
base-container: bozemanpass/static-base # built/pulled like any other container
containerfile: Containerfile.app # the wrapping build, context = app repo
port: 80
# optional: runtime-env hook, healthcheck path, detect script
Discovery then mirrors stack discovery exactly: stack fetch repo pulls it into the dev root, and enumeration is an rglob for wrapper.yml (same as locate_stacks_beneath() rglobs stack.yml). Add a stack list wrappers to surface them. clone_all_repos_for_stack() needs to learn to also fetch wrapper refs, same as it fetches container refs today.
3. The contract a wrapper must satisfy is worth writing down explicitly, because it's what keeps this generic rather than a pile of special cases:
- A base image, built once and content-hash tagged like any other container (so it participates in the existing pull-or-build / ghcr publishing path — important so users don't have to build base images locally, and so air-gapped/offline use degrades gracefully).
- An app-image build parameterized only by the app repo as build context.
- A runtime convention: what port it serves on, and how runtime config is injected. This last one is where schemes genuinely differ — nextjs needs the
apply-runtime-env.shenv-substitution trick; static content needs nothing. Making it a declared property of the wrapper rather than baked into deploy code is the key abstraction win.
4. Migrate, don't fork. The existing webapp-base and nextjs-base should become the first wrapper repos (alongside the new static one, which is trivial: nginx base + COPY . /usr/share/nginx/html). stack webapp build can survive as sugar — auto-detect the wrapper (package.json sniffing already exists in determine_base_container()) and delegate to the generic path. Buildpack-style auto-detection is nice for the convenience CLI, but I'd keep stack.yml explicit, as you suggested — declarative manifests shouldn't guess.
Things to watch
- Prior art: this is conceptually Cloud Native Buildpacks / Nixpacks territory. I'd deliberately not adopt CNB — it's heavyweight and would replace your build pipeline rather than plug into it — but it's worth borrowing its vocabulary (detect/build phases, builder contract) and being able to answer "why not buildpacks" (answer: you already have a hash-locked container build system; you just need a thin parameterized-Containerfile convention on top of it).
- Reproducibility: wrapper repos should participate in the existing lock-file scheme (like
container.lock), so a deployment pins both the app repo ref and the wrapper ref. Otherwise a wrapper repo update silently changes what everyone's images look like. - Cleanup opportunity: the
webapp-templatespecial case instack_is_external()and the_fixup_container_tag/_fixup_url_specYAML-rewriting indeploy_webapp.pyare symptoms of webapp-ness not being in the model. Once a wrapped container is a normal stack component, those should be deletable — a wrapped component with a declared port composes with the existinghttp-proxyspec machinery like anything else. - Bootstrap ordering: building a stack now has a cross-repo dependency (app repo + wrapper repo + possibly a prebuilt base image). The fetch/prepare flow needs to resolve wrappers before the container build starts, and error messages for "wrapper not fetched" should be good, since this will be a common first-run stumble.
Overall: I'd frame it as "formalize the env-var seam that build_webapp.py already uses into a named, discoverable wrapper contract, hosted in repos and resolved like external containers." The static-content case then becomes a ~20-line wrapper repo and a good proof that the abstraction is real. Happy to sketch the wrapper.yml schema and the process_container() changes in more detail if you want to take it further.