A three-tier bundle¶
Your first bundle ships one application and one database. Most products are not shaped like that. This is the next shape up: a frontend, a backend and a database, where each tier publishes its own port and holds only the credentials it needs.
Everything here is drawn from
testdata/bundle-web/,
which the acceptance run installs against real Docker on every change — so the
example is a bundle that demonstrably works, not one that used to.
What the tiers are¶
| Tier | Publishes | Holds |
|---|---|---|
frontend |
the web port | nothing |
backend |
the API port | the database password, the session key |
db |
nothing | the database password |
The frontend holding nothing is the point, not an omission. It is the tier exposed to the internet, and a tier that was never given a credential cannot leak one.
Two ports, two parameters¶
Each published port is a parameter, and the
services list names the tier that consumes it:
parameters:
http_port:
type: port
default: 18080
description: Port the web interface is published on
services: [frontend]
api_port:
type: port
default: 18081
description: Port the API is published on
services: [backend]
log_level:
type: enum
values: [debug, info, warn, error]
default: info
services: [frontend, backend]
morzer config set http_port=8443 then re-creates the frontend only — the
backend keeps serving and the database is never touched.
List every tier that reads the value
Nothing checks that services matches your Compose files. If both tiers
interpolate ${WEB_PARAM_LOG_LEVEL} but you list only the frontend,
config set log_level=debug re-creates the frontend, reports success, and
leaves the backend running the old value.
log_level above lists both, which is why.
A parameter with no services¶
max_upload declares none:
max_upload:
type: bytes
default: 25MiB
description: Largest accepted upload
The backend reads it from its rendered configuration file at start-up, so
re-creating a container would not apply it — a full apply would. Declaring no
services is how you say that, and config set then tells the operator the
change waits for the next apply rather than claiming it took effect.
The Compose file¶
Host ports come from the parameters; container ports are fixed by your images:
services:
frontend:
image: ${WEB_IMAGE_FRONTEND:-…}
ports:
- "${WEB_PARAM_HTTP_PORT:-18080}:8080"
environment:
WEB_LOG_LEVEL: ${WEB_PARAM_LOG_LEVEL:-info}
WEB_API_URL: http://backend:8080
depends_on:
- backend
backend:
image: ${WEB_IMAGE_BACKEND:-…}
ports:
- "${WEB_PARAM_API_PORT:-18081}:8080"
secrets:
- db_password
- session_key
The frontend has no secrets: block. Tiers reach each other over the Compose
network by service name (http://backend:8080), so only what an operator must
reach from outside needs publishing at all.
Requirements and health follow the parameters¶
Both, for both tiers:
requirements:
ports:
- "{{ .Parameters.http_port }}"
- "{{ .Parameters.api_port }}"
health:
checks:
- {name: web, type: http, url: "http://127.0.0.1:{{ .Parameters.http_port }}/health/ready"}
- {name: api, type: http, url: "http://127.0.0.1:{{ .Parameters.api_port }}/health/ready"}
Writing a literal here is the mistake this exists to prevent: the deployment
publishes the port the operator chose, preflight checks the one you wrote, and
apply fails at wait for health checks on a system that is working perfectly.
Per-tier credentials¶
The secret schema's services is what gets restarted on rotation, so it names
the tiers that actually hold each value:
secrets:
- name: db_password
description: Database password used by the backend
required: true
generator: {kind: password, length: 32}
services: [backend, db]
- name: session_key
description: Key the backend signs session cookies with
required: true
generator: {kind: hex, length: 64}
services: [backend]
Rotating session_key bounces the backend. Rotating db_password bounces the
backend and the database. Neither touches the frontend, because the frontend
never had either.
Do not reach for a parameter here
A parameter is the wrong home for anything secret: its value is visible in
docker inspect, in status --json, in the journal and in
installation.yaml in the clear. Credentials belong in the secret schema,
which encrypts them at rest and renders them to tmpfs as files.
Profiles¶
The database is a profile rather than a fixed service, because whether it runs here is the operator's decision:
runtime:
files:
- compose/compose.yaml
profiles:
embedded: [compose/compose.embedded.yaml]
external-db: [compose/compose.external-db.yaml]
The backend keeps db_password under both. Someone else's database still
requires authenticating to it; only the server moves.
Installing it¶
morzer init --release ./bundle-web --profile embedded \
--set http_port=8443 --set api_port=9443
morzer apply
And afterwards, one tier at a time:
morzer config set http_port=8080 # re-creates the frontend, nothing else
morzer config list # what is set, and where each value came from