#Stacks
A stack is a Compose file Bay runs as one unit: an Alepha app plus the services it needs (Postgres, Redis, Keycloak, a queue), each environment isolated from the others, with routes, variables and backups managed by Bay.
Use a stack when an app needs more than the SQLite database Bay provisions for a lone app. For the end-to-end walkthrough of a Postgres and Redis stack from the project's side, read Postgres and Redis on a Bay Stack; this page is the Bay reference.
#The compose file
A standard Compose file, plus an x-lore field on the services Bay should
treat specially. It is named for Lore, not Bay, because the field means the
same on every target a stack can run on.
1services: 2 db: 3 image: postgres:17-alpine 4 environment: 5 POSTGRES_USER: app 6 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 7 POSTGRES_DB: app 8 volumes: [pgdata:/var/lib/postgresql/data] 9 healthcheck:10 test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]11 interval: 2s12 retries: 3013 x-lore:14 backup:15 postgres: { user: app, db: app }16 redis:17 image: redis:7-alpine18 volumes: [redisdata:/data]19 web:20 depends_on: [db, redis]21 x-lore:22 app: myapp23 routes: ["@"]24volumes:25 pgdata:26 redisdata:
x-lore key |
Meaning |
|---|---|
app |
the service is an app service: Bay runs it itself (blue/green, hardened) from the artifact deployed under this name, instead of Compose running an image |
routes |
labels under the environment's base domain: "@" is the base domain itself, "auth" is auth.<base> |
port |
the container port routes reach, when the service publishes several or none |
runtime |
an app service's runtime image, instead of the one Bay picks |
tcp |
open this port on every host interface and forward it raw to the same container port. A Compose service only; no TLS and no Host routing, so the service guards it itself (MQTT over TLS, for example) |
tls |
with tcp: Bay terminates TLS on that port with the certificate of the name the client asks for, and forwards plain TCP to the same container port. A client that names none gets the environment's first route. Renewals are Bay's, so the service holds no certificate |
backup |
how to dump the service: {postgres: {user, db}}, or {command, restore} |
Unknown keys and malformed values are refused at deploy, naming the service, so a typo is never silently ignored. Full domains never appear in the file, since production and staging need different ones: they are routes.
Bay reads the file through docker compose config, then refuses, naming what
it refuses:
build,container_nameandprivileged;hostasnetwork_mode,pid,ipcoruserns_mode;container:asnetwork_mode,pid,ipcorvolumes_from, which would reach any container on the host.service:<name>, or a plain service name undervolumes_from, names one of the stack's own;- a service with neither
imagenorx-lore.app; - the Docker socket as a mount;
- a bind mount, an
env_file, or afile:undersecretsorconfigs, outside the bundle or of a path the bundle does not carry. A stack's variables are a plain file on the host, so anenv_filecould otherwise read another environment's passwords, and for the same reason its path cannot hold a variable; includeandextendsfrom another file: a stack is one compose file;- a named volume with
driver_opts, which are mount options: a bind through them would reach any host path; - a named volume or a network marked
external, or with anameof its own: Bay names both after the environment, and either one would reach whatever already carries that name, another environment's included; - a Compose service that
depends_onan app service; - a
${VAR}with no value and no default; - a variable named
COMPOSE_*: variables reach Compose's own environment, where such a name configures Compose itself.
ports are rewritten to a loopback port Bay allocates, so the host port in the
file is ignored. Nothing else is checked: devices, cap_add, security_opt,
cgroup and uts reach Docker as written, so whoever deploys a stack can reach
the host.
#Environments
A stack is deployed per environment, addressed as <stack>/<env>. Each one is
its own Compose project (mcs-production, mcs-staging), with its own network,
volumes, variables and containers: a file cannot name production's Postgres, its
volumes or its variables from staging, and destroying staging leaves production
alone. That stops a mistake, not a hostile file: what the list above leaves
unchecked still reaches the host.
#Deploying
These commands, and every other bay command on this page, run on the host with
BAY_SOCKET set, as in Operating.
export BAY_SOCKET=/run/bay/control.sock
bay stack deploy mcs/production compose.yml # or a directory, an archive, or - for stdin
bay stack deploy mcs/production compose.yml --dry-run
bay stack deploy mcs/production # reapply the current revision
⚠️ A bundle is a whole directory, never one file. compose.yml as the
argument packs the directory it sits in, leaving out only .git, node_modules
and symlinks. So scp compose.yml host:, then
bay stack deploy mcs/production compose.yml from your home directory, bundles
all of $HOME, .ssh included, into the revision. Give each stack a directory
of its own. A bundle is capped at 16 MiB, uploaded and unpacked alike.
Each deploy is a numbered revision that pins the image digests it ran. An
image that no registry serves, loaded on the host with docker load, is
declared pull_policy: never on its service: Bay pulls nothing and pins the
host's image id, and refuses the deploy when the host does not hold it.
Only the services whose definition changed are recreated, and app services are
restarted with the new network and variables. A bad revision is undone by
reapplying an earlier one, with the same digests:
bay stack rollback mcs/production --to 3
--keep-releases bounds revisions too: by default Bay keeps the current one and
its predecessor, and deletes the older ones with the images only they pinned, so
--to cannot reach further back.
An app service waits for its first artifact. It comes from the project
(alepha deploy with bay({ stack: "mcs" })), or by hand into one service:
bay deploy mcs/production/web myapp.tar.zst
Either way only that service is replaced, blue/green; Postgres and Redis keep running.
#Variables
ssh HOST 'BAY_SOCKET=/run/bay/control.sock bay stack env set mcs/production -' < .env.stack.production
bay stack env list mcs/production # names only
bay stack env unset mcs/production KEY...
Variables fill ${VAR} in the compose file and reach app services, from the
next bay stack deploy. As for an app, values come from stdin or a file, never
from the command line.
#Routes and domains
Each environment gets a base domain: <stack>.<base-domain> for production,
<stack>-<env>.<base-domain> otherwise. routes labels resolve against it. A
real domain is a route of the stack, so it survives every app deploy:
bay stack route mcs/production web myapp.com
bay stack route list mcs/production
bay stack route remove mcs/production myapp.com
The domain option of bay() is not used for an app in a stack.
#Private registries
ssh HOST 'BAY_SOCKET=/run/bay/control.sock bay stack registry add mcs/production ghcr.io --username bot' < token.txt
bay stack registry list mcs/production # hosts only
bay stack registry remove mcs/production ghcr.io
The password is read from stdin, and stored base64-encoded, not encrypted, in the
environment's Docker config at mode 0600. Docker Hub pulls go through the
host's registry mirror when one is set.
#Observing and operating
bay stack list # every stack environment
bay stack ps mcs/production # services, kind, state, domains
bay stack logs mcs/production db -n 500
bay stack restart mcs/production web # an app service blue/green, others in place
bay stack exec mcs/production db psql -U app app
bay stack exec is no control API call: it runs docker exec -it on the host,
so it needs a terminal (ssh -t HOST) and Docker access, which bay-control
alone does not give. It is never a verb the
Lore connector can send.
#Backups and volumes
A service is data too, so it declares how to dump itself in x-lore.backup:
{postgres: {user, db}}runspg_dumpin the service's container and checks the dump withpg_restore --list.{command, restore}takes the command's stdout as the dump, and restores by runningrestorewith the dump on stdin.
Dumps ride the same schedule, upload and pruning as app backups; an empty or failing hook is a failed backup.
bay backup list mcs/production db
bay backup restore mcs/production db <backup>
bay backup restore mcs/staging db <backup> --from mcs/production # production data into staging
A restore overwrites the service's data, so it asks you to type the target
(mcs/production/db) to confirm; --yes skips the question in a script.
A volume no hook covers is named in every backup response, in bay status
and in bay stack volumes (with its size): "not backed up: volume
redisdata". Redis holding caches and sessions is fine to leave so; anything
else deserves a hook.
bay stack volumes mcs/production
#Stopping and destroying
bay stack stop mcs/staging # durably out of service, apps included
bay stack start mcs/staging # back, from the current revision
bay stack destroy mcs/staging # remove containers, routes and revisions; volumes, variables, registries kept
bay stack destroy mcs/staging --purge # and delete the volumes, variables and registries (asks; --yes to skip)
Either way an app service's directory under apps/, its .env included, stays
on disk.
alepha infra down refuses for an app in a stack: remove the service from the
compose file and redeploy the stack, or destroy the environment here.