Shared Services on a Home Server
In my home server post I mentioned the trick that makes the whole setup pleasant to live with: I don't give every project its own database. Instead I run a small set of shared backing services once, and every app connects to them. A few people asked how that actually works, so here's the wiring.
The idea, in one sentence
Run one PostgreSQL, one MongoDB, one Redis, and one MinIO — then let every project share them. The apps stay disposable; the data layer stays stable. I can rebuild or throw away an app whenever I like, because the data it depends on lives independently of it.
A network they all share
Everything hangs off a single Docker network I call infra. I create it once:
docker network create infra
Each backing service is its own small Compose stack that joins this network
rather than defining it. Apps join the same network too, and reach a service simply
by its container name — no published ports, nothing exposed to the host. That last
part matters: the databases are never reachable from outside, only from other
containers on infra.
Postgres, the anchor
Postgres does the most work for me, so it's the one I treat most carefully — a pinned image, a bind mount to a known host directory so data survives restarts, a password from the environment, and a healthcheck:
services:
postgres:
image: postgres:16-alpine
container_name: postgres
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
- infra
networks:
infra:
external: true
Because the data lives in a bind mount (/data/postgres on the host), there's no
top-level volumes: block to declare — the directory is just there on disk, which
also makes it trivial to find when backup time comes.
The external: true is the important line — it tells Compose this network already
exists (the one I created above) instead of making a new project-scoped one.
That ${POSTGRES_PASSWORD} comes from an .env file sitting next to the compose
file — the small set of values needed to bring the service up:
# srv/infra/postgres/.env
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=use-a-strong-one
This is worth being clear about, because there are really two layers of
configuration. The infra .env above belongs to the database itself and is
only about starting it. Each app carries its own separate env — just the handful
of variables it needs to reach the shared instance. The apps never see the
database's bootstrap config; they only need to know how to reach it.
When a new project needs a database, I don't stand up another Postgres. I create a role and a database inside the one that's already running:
docker exec -it postgres psql -U postgres -c "CREATE USER myapp WITH PASSWORD 'another-strong-one';"
docker exec -it postgres createdb -U postgres -O myapp myapp_production
Now the app has its own user owning its own database, isolated from every other
project even though they all share one Postgres. It reaches the server over the
network as myapp@postgres:5432/myapp_production — note the host is just postgres,
the container name on the shared network.
On the app's side, joining is the mirror image. The app declares the same external network and hands the container those pieces as environment variables — host, database, user, password:
services:
web:
image: ghcr.io/me/myapp:latest
environment:
- DATABASE_HOST=${DATABASE_HOST}
- DATABASE_NAME=${POSTGRES_DB}
- DATABASE_USER=${POSTGRES_USER}
- DATABASE_PASSWORD=${POSTGRES_PASSWORD}
networks:
- infra
networks:
infra:
external: true
Those values live in the app's own .env, separate from the infra one — this is
the app-level layer I mentioned earlier:
# Shared PostgreSQL on the `infra` network (container name `postgres`).
DATABASE_HOST=postgres
POSTGRES_USER=myapp
POSTGRES_PASSWORD=another-strong-one
POSTGRES_DB=myapp_production
The symmetry is the point: the service and the app describe the same network the same way, and neither owns it. That's what lets me start, stop, or rebuild either side without the other noticing.
What about local dev?
There's a fair objection to all this: if an app expects a shared postgres on the
infra network, how do you run it on a laptop where none of that exists? Each
project also ships a self-contained dev stack — a second compose file that bundles
its own throwaway Postgres right alongside the app:
docker compose -f docker-compose.dev.yml up --build
No external network, no shared services, no .env to fill in — the dev credentials
sit inline and the database is a local volume you can reset whenever you like. The
shared-services setup is a server concern; on a laptop each app is happily
standalone. Same app, two ways to wire its dependencies depending on where it runs.
The rest of the toolbox
The other three follow the exact same pattern — their own stack, joined to infra,
no exposed ports:
- Redis — caching and background job queues (Sidekiq, BullMQ, and friends).
- MongoDB — for the document-shaped projects that don't want a schema.
- MinIO — S3-compatible object storage, so anything with file uploads gets a real bucket without reaching for the cloud.
One instance of each, shared by everything. Memory usage stays sane and there's just one of each thing to reason about.
Kept private, backed up
Because none of these publish ports, the only things the internet can see are the apps I deliberately put behind Traefik. The data services sit quietly on the internal network.
That leaves two shared networks, each with one job — web for public ingress,
infra for private data — and an app straddles both: a public face on web, a
data-facing side on infra.
Internet
│ :443
▼
┌─────────────────┐
│ Traefik │ ingress + TLS
└────────┬────────┘
│
═════════════════ web ══════════════════ public network
│
┌────────┴────────┐
│ app frontend │ the only public piece
└────────┬────────┘
│ app-internal link
┌────────┴────────┐
│ app / API │
└────────┬────────┘
│
════════════════ infra ═════════════════ private network
│ │ │ │
┌────┴───┐ ┌────┴───┐ ┌────┴───┐ ┌────┴──┐
│postgres│ │ redis │ │mongodb │ │ minio │
└────────┘ └────────┘ └────────┘ └───────┘
no published ports — reachable only on infra
A container only resolves names on a network it has joined, so keeping Postgres on
infra and off web is the whole privacy story: to anything on the public side, the
database simply doesn't exist.
Backups are refreshingly boring. The state I care about lives in a known set of
places — the Postgres bind mount at /data/postgres and the volumes the other
services use — with a nightly logical dump of Postgres on top:
docker exec postgres pg_dumpall -U postgres | gzip > /backups/pg-$(date +%F).sql.gz
A small cron job runs that and syncs the /backups folder off-site. One routine
covers every project, because every project shares the same database. I keep both on
purpose: the bind-mount directory restores the exact running state fast, while the
logical dump is portable — it loads cleanly onto a fresh box or a newer Postgres,
which raw data files won't always do.
A new project is just a connection string
That's the payoff. Spinning up something new no longer means provisioning
infrastructure — it means creating a database, joining the infra network, and
pointing an environment variable at it. The heavy, stateful pieces were set up once
and now quietly serve everything.
Next in the series I'll cover how apps actually land on this setup — the GitHub Actions pipeline that builds an image, pushes it to the registry, and rolls it out onto the server automatically.
Back to all posts