on this page

A real app is never one thing. It’s a frontend, a backend, a database, and usually a cache, all of which have to find each other and agree to cooperate. The first time you try to wire that up by hand, you install Postgres locally, forget the password you set, install Redis, discover your teammate is on a different version of Node, and spend an evening losing to your own terminal.

This guide takes the pieces from Docker without the buzzwords and points them at a realistic stack. If “image,” “container,” and “volume” still feel fuzzy, read that one first. We’re not re-explaining what a container is here. We’re going to put four of them in a room together and make them talk.

The app we’re dockerizing

Here’s the stack, the kind of thing you actually build:

  • A frontend: React, Angular, or Astro. The thing the user looks at.
  • A backend API: Node and Express. API stands for Application Programming Interface, which is a fancy way of saying “the program your frontend talks to instead of touching the database directly.”
  • A database: PostgreSQL (Postgres for short), where the real data lives.
  • A cache: Redis, an in-memory store you use to remember things quickly so you don’t hammer the database for the same answer over and over.

Four services. In local development, the dream is one command that brings the whole thing up. That command is docker compose up, and getting there is most of this guide.

The mental model: one network, names instead of addresses

The single most useful thing Docker Compose does for a multi-service app is put every service on a shared network and give each one a name.

Here’s why that matters. Normally, for one program to talk to another over the network, it needs an address: an IP address (Internet Protocol address, the string of numbers that identifies a machine). IP addresses are annoying because they change. Your Postgres container might be 172.18.0.3 today and something else tomorrow.

Compose sidesteps the whole problem. Every service you define gets a hostname equal to its service name, and they all sit on the same private network. So your backend doesn’t connect to 172.18.0.3. It connects to db, and Docker quietly figures out which container that is. Name a service db, and db is its address. Name it redis, same deal.

This is the part that clicks slowly and then feels obvious. The service name in your Compose file is the hostname your code uses to reach it.

The Compose file

Here’s a Compose file for the whole stack. It’s longer than the toy example in the intro guide, but every block earns its place. Read it once, then we’ll go through the parts that matter.

# docker-compose.yml
services:
  frontend:
    build: ./frontend
    ports:
      - "5173:5173"
    environment:
      # The browser uses this to find the API. More on this below.
      - VITE_API_URL=http://localhost:4000
    depends_on:
      - backend

  backend:
    build: ./backend
    ports:
      - "4000:4000"
    environment:
      # "db" and "redis" here are the service names below, used as hostnames.
      - DATABASE_URL=postgres://appuser:${POSTGRES_PASSWORD}@db:5432/appdb
      - REDIS_URL=redis://redis:6379
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=appdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      # Optional: only so you can connect with a GUI from your laptop.
      - "5432:5432"

  redis:
    image: redis:7-alpine
    # No volume here on purpose: a cache is allowed to forget.

volumes:
  postgres_data:

Let me unpack the pieces a beginner trips on.

build: ./backend tells Compose to build an image from the Dockerfile in that folder. image: postgres:16-alpine does the opposite: it pulls a ready-made image from Docker Hub

Heads up. You're leaving raindev.fyi

This link heads to hub.docker.com, an external site we don't control. Cool to keep going?

Continue
, because nobody writes their own Postgres. You build the code you wrote and pull the things other people maintain.

The db and redis words inside DATABASE_URL and REDIS_URL are not magic strings. They are exactly the service names defined lower in the file. The backend connects to the hostname db on port 5432 (Postgres’s default port) and redis on 6379 (Redis’s default). Change the service name to database and you’d have to write @database:5432 instead. The two always match.

depends_on controls start order, so db boots before backend tries to reach it. Read the next sentence twice, because it bites everyone: depends_on waits for the container to start, not for Postgres inside it to be ready to accept connections. Postgres takes a second or two to wake up after its container exists. Your backend should retry its database connection on startup rather than assuming db answers instantly. A short retry loop in your code saves you from a confusing crash on the very first compose up.

The volumes block at the bottom declares a named volume called postgres_data, and the db service mounts it at /var/lib/postgresql/data, which is where Postgres keeps everything. Without this, your data lives inside the container, and the container is disposable. Run docker compose down and your database evaporates along with every row in it. With the named volume, the data outlives the container, so you can stop and restart all day and your records stay put.

Redis has no volume on purpose. A cache is the one thing you’re allowed to lose. If Redis forgets, your app just recomputes the answer and moves on. That’s the whole job of a cache.

Secrets: out of the file

You may have noticed ${POSTGRES_PASSWORD} and ${JWT_SECRET} instead of real values. (JWT is a JSON Web Token, a common way to prove a user is logged in. The exact mechanics don’t matter here. What matters is that its secret signing key must never leak.)

Those ${...} references pull from a .env file sitting next to your Compose file:

# .env  (this file is gitignored, never committed)
POSTGRES_PASSWORD=some-long-random-string
JWT_SECRET=another-long-random-string

Compose reads .env automatically and substitutes the values in. The point is that your docker-compose.yml is safe to commit to GitHub, because the actual secrets live in a file you never commit. Add .env to your .gitignore before you do anything else.

Running it

With the file in place, the commands are short:

# Build images where needed and start all four services in the foreground
docker compose up

# Same, but detached: runs in the background and hands your terminal back
docker compose up -d

# Tail the logs of just the backend so you can watch it connect
docker compose logs -f backend

# Stop and remove the containers, but KEEP the postgres_data volume
docker compose down

# Stop AND delete volumes: wipes your database. Only when you mean it.
docker compose down -v

That first command is the payoff. One line, and a teammate who just cloned the repo has a frontend, a backend, Postgres, and Redis all running and wired together, without installing a single database on their machine. That convenience is the reason to bother with any of this.

The trap nobody warns you about: the browser is not on the network

This is the part that genuinely confuses people, so I’m giving it its own section.

Your backend reaches the database as db because both run as containers on Docker’s private network. Fine. So you might assume the frontend reaches the backend as backend. It does not, and here’s the catch.

Your frontend code runs in two different places. The Astro or React build might run inside a container, but the JavaScript that fetches data runs in the user’s browser, on their laptop, miles from your Docker network. The browser has never heard of a host called backend. That name only means something inside Docker.

So the browser needs a real, reachable URL for the API. In local dev that’s http://localhost:4000, because you mapped the backend’s port out to your machine with ports: - "4000:4000". That’s exactly what VITE_API_URL=http://localhost:4000 is doing in the Compose file: handing the browser an address it can actually load.

The rule, burned into a sentence: backend-to-database uses service names; browser-to-backend uses a public URL. If you ever see your frontend trying to fetch http://backend:4000 and failing, this is why. For the longer version of how browsers, ports, and addresses fit together, the networking guide walks the whole path.

Production is not this

Everything above is a local-development setup. It’s great at that job and bad at being production. The biggest difference surprises beginners, so here it is plainly.

In production, you usually do not run the frontend as a long-lived container.

A React, Angular, or Astro app builds down to static files: plain HTML, CSS, and JavaScript. Once they’re built, they don’t need a running server doing anything clever. They just need to be handed to a browser. That’s what a static host or CDN (Content Delivery Network, a fleet of servers that keep copies of your files close to users) is for. You upload the built files to Cloudflare Pages

Heads up. You're leaving raindev.fyi

This link heads to pages.cloudflare.com, an external site we don't control. Cool to keep going?

Continue
or Vercel

Heads up. You're leaving raindev.fyi

This link heads to vercel.com, an external site we don't control. Cool to keep going?

Continue
, and they serve them fast and cheap, often free.

So the frontend gets Dockerized for local development, where docker compose up brings up the whole stack in one shot, and then it ships a completely different way: built once and handed to a CDN as static files. The container was a dev convenience, not the production artifact.

The backend, database, and cache are different animals. They’re genuinely long-running services: the backend listens for requests around the clock, Postgres has to stay up and remember everything, Redis holds state in memory. Those run as real services. You can put them on a platform like Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.app, an external site we don't control. Cool to keep going?

Continue
or Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
, which take a Dockerfile or a repo and run it for you, or on your own Docker hosts if you want the control and the pager duty that comes with it.

Where each piece actually lives

Here’s the stack mapped to reality. The “Long-running?” column is the tell: anything that has to stay awake and remember things is a real service, and anything that builds to static files is not.

ServiceLocal dev (Compose)Production homeLong-running?Why
Frontend (React / Angular / Astro)Container, build: ./frontendStatic host / CDN (Cloudflare Pages, Vercel)NoBuilds to plain HTML/CSS/JS; just needs serving
Backend (Node / Express)Container, build: ./backendService host (Railway, Render, your own Docker host)YesListens for requests continuously
Database (PostgreSQL)Container + named volumeManaged Postgres (Railway, Render, or a managed DB)YesMust stay up and never lose data
Cache (Redis)Container, no volumeManaged Redis, or skip if optionalYesHolds state in memory; safe to lose

Two things on that table worth saying out loud. The frontend is the only row that escapes the container in production, and the database is the only row where losing the data is a real outage rather than a shrug.

Common mistakes

These are the ones that cost people an evening. Most of them come from treating the dev setup as if it were production.

  • Deploying the dev Compose file straight to production. That file has the database password derived from a local .env, ports flung open to your laptop, and a frontend container you don’t actually want in prod. It’s tuned for convenience, not for the open internet. Treat it as a dev tool. Production wants managed services and real secret handling, not docker-compose.yml copied onto a server. See the production guide for how to harden the parts that do ship.
  • The browser talking to the backend by service name. Covered above, and worth repeating because it’s the single most common one. db and redis and backend are hostnames inside Docker. The browser can’t see them. The browser needs a public URL.
  • No volume, so the database resets. Forget the named volume on Postgres and every docker compose down quietly wipes your data. You’ll add a user, restart, and wonder where they went. The volume is what makes the data survive.
  • Secrets committed in the Compose file. Put a real password in docker-compose.yml, push it, and it’s in your Git history forever. Use .env, gitignore it, and rotate anything that ever leaked.

Tying it back

Docker didn’t make your full-stack app simpler. It made the moving parts honest. The database is a real service that has to stay up and remember things. The cache is allowed to forget. The frontend is just files. Compose lets you run all of that locally with one command, and production lets each piece live where it actually belongs.

That’s the whole arc of shipping a real project: build the thing on your laptop where docker compose up gives you the full stack in seconds, then deploy each part to the home that suits it. Frontend to a CDN, backend and database to real services. When you’re ready to make the production side solid (locked-down secrets, sane defaults, nothing flapping in the wind), that’s the next guide. For now, you can stand up a four-service app and explain, out loud, where every piece goes when real users show up.