#Postgres and Redis on a Bay Stack
A Bay deploys an app on its own with SQLite in the instance's
directory. Once the app outgrows that, or needs Redis for sessions, locks and caches shared across
restarts, it moves into a stack: a compose.yml applied on the same VPS, where Postgres and
Redis run from their official images and the app runs beside them. Bay still runs the app itself,
blue/green, from the same .tar.zst artifact, and still does the proxy, TLS and backups.
This guide takes an app from SQLite to a stack named mcs, with a production and a staging
environment on one machine.
#The compose file
Put it at the root of the app's repository:
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 healthcheck:20 test: ["CMD", "redis-cli", "ping"]21 interval: 2s22 retries: 3023 web:24 depends_on: [db, redis]25 x-lore:26 app: myapp27 routes: ["@"]28volumes:29 pgdata:30 redisdata:
webis the app.x-lore: { app: myapp }marks it as a service Bay runs from an artifact, and names the app deployed into it: thenameofinfra()(or thepackage.jsonname). It has noimage, and Compose never starts it.dbandredisare plain Compose services. They reach each other and the app by service name on the stack's own network (db:5432,redis:6379). Bay publishes nothing to the internet but the routes.routes: ["@"]serves the app on the environment's base domain. Labels are relative (@,api,auth), never full domains, so one file serves production and staging.- The healthcheck asks over TCP,
-h 127.0.0.1. Bay starts the app once every service it depends on is healthy. On its first boot the Postgres image runs a temporary server on the unix socket only, before it createsPOSTGRES_DB, so a socket check (pg_isreadywithout-h) reports healthy while the database does not exist yet. The temporary server never listens on TCP. x-lore.backuptells Bay how to dump Postgres, under the same schedule as an app's SQLite database. See what backups cover.${POSTGRES_PASSWORD}comes from the stack environment's variables, below. A variable the file uses and the environment lacks is refused at deploy rather than left empty.
This exact file is applied on a real Docker daemon in Bay's own test suite, with an app reaching Postgres and Redis through it.
#Migrations for Postgres
The app's migrations live in migrations/<provider>/, and a SQLite app only has
migrations/sqlite/. Generate the Postgres ones once against a local Postgres, then commit them:
DATABASE_URL=postgres://app:app@localhost:5432/app alepha db migrations create --name=initial_schema
The app applies them itself at boot, as it does on SQLite. See Migrations.
#Variables
Each stack environment has its own variables. DATABASE_URL and REDIS_URL are what Alepha reads,
pointing at the services by name; POSTGRES_PASSWORD fills the compose file:
ssh deploy@bay.example.com 'bay stack env set mcs/production -' <<'EOF'
POSTGRES_PASSWORD=a-long-random-password
DATABASE_URL=postgres://app:a-long-random-password@db:5432/app
REDIS_URL=redis://redis:6379
EOF
Then the same for mcs/staging, with its own password. bay stack env list mcs/production shows
the names, never the values.
The app sees Bay's own keys first (APP_SECRET, SERVER_PORT, ...), then its own secrets (from
.env.production, sent by alepha deploy), then the stack's variables. DATABASE_URL is the one
exception: Bay gives an app that declares a database a SQLite file only when nobody supplied a
database, and a stack's DATABASE_URL counts, so the app gets Postgres.
#Deploy the stack, then the app
The stack goes first: it creates the network, the volumes, Postgres and Redis, and records web as
waiting for its first artifact.
scp compose.yml deploy@bay.example.com:
ssh deploy@bay.example.com 'bay stack deploy mcs/production compose.yml'
ssh deploy@bay.example.com 'bay stack deploy mcs/staging compose.yml'
Then the app. Point bay() at the stack:
1import { defineConfig } from "alepha/cli/config"; 2import { bay, infra } from "alepha/cli/infra"; 3 4export default defineConfig({ 5 plugins: [ 6 infra({ 7 name: "myapp", 8 environments: { 9 production: bay({ host: "deploy@bay.example.com", stack: "mcs" }),10 staging: bay({ host: "deploy@bay.example.com", stack: "mcs" }),11 },12 }),13 ],14});
alepha deploy --env staging
alepha deploy builds and packs as usual, asks the Bay which service of mcs/staging runs
myapp, and redeploys that one service, blue/green: the new release starts beside the old one and
takes the traffic once healthy. Postgres and Redis keep running. alepha infra status --env staging
reports the service and the secrets set on it.
A later change to compose.yml (a new service, a new Postgres tag) is a bay stack deploy again.
It recreates only the services whose definition changed, and restarts the app service with the new
network and variables. bay stack rollback mcs/production --to 1 reapplies an earlier revision with
the image digests it ran.
#From Lore
On a Bay enrolled in Lore as an estate, the same steps run from the
lore CLI, with nothing to copy over ssh:
lore stack create --file compose.yml --estate vps-ovh
lore stack secrets set --file .env.stack.production --env production
lore stack deploy --env production
.env.stack.production holds the three variables above, and stays out of git. The app is then
deployed with alepha deploy as above, or from Lore with lore deploy.
#Production and staging on one VPS
Each environment is its own Compose project (mcs-production, mcs-staging): its own network,
volumes, variables and containers. Staging cannot reach production's Postgres, and destroying
staging leaves production untouched.
Each gets a base domain, mcs.<bay's domain> for production and mcs-staging.<bay's domain> for
staging, which @ resolves against. Serve a real domain from a service with a route:
ssh deploy@bay.example.com 'bay stack route mcs/production web myapp.com'
The domain option of bay() is not used in a stack: routes belong to the stack, so they survive
every app deploy.
#What backups cover
- Postgres is dumped with
pg_dumpinside its container, on Bay's backup schedule, because the file declaresx-lore.backup.bay backup list mcs/production dblists the dumps andbay backup restore mcs/production db <backup>restores one, on the host. - Redis is not backed up: it holds caches, sessions and locks that the app rebuilds. Declare a
x-lore.backupcommand for it if yours holds anything else. - Uploads go where they go on a lone app: Bay's bucket when one is configured, else the
instance's
storage/directory, backed up as before.
bay stack volumes mcs/production lists every volume with the hook that covers it, and names the
ones nothing covers rather than staying silent about them.
#Taking it down
alepha infra down refuses for an app in a stack: removing one service would leave the compose
file naming a service Bay no longer runs. Remove the service from compose.yml and deploy the
stack, or take the whole environment down on the host:
ssh deploy@bay.example.com 'bay stack destroy mcs/staging'
A plain destroy keeps the volumes, the variables and the registry credentials. Deleting the data is
a separate, confirmed step, --purge, on the host.