Back to all posts
6 min read

Shared Services on a Home Server

Self-HostingDockerPostgreSQL

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