To dockerize a multi-tier application, give each tier its own container, which here means a frontend, a backend and a database, and use Docker Compose to start them together on one private network. The services find each other by service name, the database keeps its data in a named volume, and only the frontend is exposed to your machine.

That's the short version. The long version is where most beginners get stuck: the API that works on the laptop but not in a container, the database that loses everything after docker compose down -v, the React app that can't find the backend. This guide builds a small React + Node.js + PostgreSQL setup and covers those problems as they come up.

What does a multi-tier application look like in Docker?

A three-tier web app has three parts:

• Frontend: the user interface (React here), built into static files and served by Nginx.
• Backend: the API and business logic (Node.js).
• Database: where the data lives (PostgreSQL).
Each one runs in its own container:

Browser | v Frontend (Nginx serving React) --/api--> Backend (Node.js) --> Database (PostgreSQL) 

Separate containers mean separate dependencies. You can rebuild the backend without touching the database, read one service's logs without the other two, and swap PostgreSQL versions without reinstalling anything on your machine.

How should you structure the project?

my-app/ ├── frontend/ │ ├── Dockerfile │ ├── .dockerignore │ ├── nginx.conf │ ├── package.json │ └── src/ ├── backend/ │ ├── Dockerfile │ ├── .dockerignore │ ├── package.json │ └── server.js ├── compose.yaml ├── .env # local only, never committed └── .gitignore 

Note where the .dockerignore files are. Docker reads .dockerignore from the root of the build context, and since Compose builds from ./frontend and ./backend, each folder needs its own. A single file at the project root would be ignored.

The database uses the official postgres image, so it doesn't need a Dockerfile.

Writing the backend Dockerfile

In backend/Dockerfile:

FROM node:24-alpine WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev COPY . . USER node EXPOSE 3000 CMD ["node", "server.js"] 

Line by line:

• FROM node:24-alpine uses a small image with a pinned Node.js major version.
• ENV NODE_ENV=production comes before the install, and npm ci --omit=dev keeps test and build tools out of the final image.
• Copying package*.json first means Docker caches the dependency layer. Edit server.js and the rebuild skips npm ci.
• USER node runs the app as the non-root user the Node image already includes. The code stays owned by root, so the app can read it but can't modify it. If your app writes files, such as uploads, give it one writable directory.
• EXPOSE 3000 is documentation only. It doesn't publish anything to your machine.
One thing to check in server.js: the server must listen on 0.0.0.0, not 127.0.0.1 or localhost. Inside a container, localhost means only that container, so nothing else can reach it.

app.listen(3000, '0.0.0.0', () => console.log('API on 3000')); 

Writing the frontend Dockerfile (multi-stage)

React compiles to static files, so you don't need Node.js at runtime. A multi-stage build does the build with Node and ships only the output with Nginx.
In frontend/Dockerfile:

FROM node:24-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:stable-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 

This assumes a Vite project, which outputs to dist. Create React App outputs to build, so change the path if that's what you use.

Getting the browser to reach the backend

This catches almost everyone. Your React code runs in the user's browser, not inside the frontend container. So fetch('http://backend:3000') fails because the browser has never heard of backend, and localhost:3000 fails because the backend port isn't published.

The clean fix is to let Nginx serve the app and forward API calls. In frontend/nginx.conf:

server { listen 80; root /usr/share/nginx/html; index index.html; # Forward API requests to the backend container location /api/ { proxy_pass http://backend:3000; } # Send unknown routes to React so client-side routing works location / { try_files $uri $uri/ /index.html; } } 

Now the frontend calls fetch('/api/users'), with no host and no port. Nginx runs inside the Compose network, where backend resolves, and passes the request on with the /api prefix intact, so define your Express routes under /api. There's no CORS setup because everything is same-origin, and the backend never needs a public port.

Connecting the tiers with Docker Compose

In compose.yaml at the project root:

services: frontend: build: ./frontend ports: - "8080:80" depends_on: - backend backend: build: ./backend environment: NODE_ENV: production DATABASE_URL: postgres://appuser:${DB_PASSWORD:?Set DB_PASSWORD}@db:5432/appdb depends_on: db: condition: service_healthy db: image: postgres:16 environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD} POSTGRES_DB: appdb volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"] interval: 5s timeout: 3s retries: 10 volumes: postgres_data: 

Put the password in a .env file next to compose.yaml. Compose reads it automatically:

# .env (add this file to .gitignore) DB_PASSWORD=use-a-long-random-local-password 

The :?Set DB_PASSWORD part makes Compose stop with a clear error if the variable is missing, rather than starting PostgreSQL with an empty password.

What's going on in this file:

• Service names are hostnames. The backend reaches PostgreSQL at db:5432. Compose puts all three services on a shared network with built-in DNS.
• Only one port is published. 8080:80 makes the frontend available at localhost:8080. The backend and database are reachable by other containers on the network, but not from your machine or the internet.
• The health check controls startup order. condition: service_healthy makes the backend wait until PostgreSQL accepts connections, not just until its container starts. Your app should still retry if the database drops later, because the health check only covers startup.
• The named volume keeps your data. The database files live in postgres_data, outside the container, so you can remove and recreate the container without losing them.

Docker best practices beginners should follow

Keep secrets out of images and Git

Never put passwords or API keys in a Dockerfile. Anything written there ends up in the image layers, where anyone with the image can read it. A gitignored .env file is fine for local development. In production, use your platform's secret manager or Docker secrets.

Ignore what the image doesn't need

A typical backend/.dockerignore:

node_modules npm-debug.log .git .env coverage 

Excluding node_modules matters more than it looks. Copying your laptop's modules into a Linux Alpine image can break native packages such as bcrypt.

Pin versions, then update them on purpose

node:24-alpine and postgres:16 are pinned to a major version. latest is not. Combined with package-lock.json and npm ci, everyone builds the same thing. Pinning isn't a reason to stop updating, though. Rebuild regularly to pick up security patches, and run an image scanner such as docker scout before anything goes to production.

Choose small images, but not only for size

Alpine-based images are small and fast to pull. They use musl instead of glibc, though, and some native Node packages behave differently there. If something odd fails on Alpine, try node:24-slim before spending an afternoon debugging.

Don't run as root

If an attacker gets into a process running as root, they have far more room to cause damage. USER node costs one line.

Volumes aren't backups

A named volume survives container restarts. It doesn't survive docker compose down -v, a deleted volume or a failed disk. For data you care about, schedule pg_dump backups and test restoring one at least once.

How do you run and test it?

docker compose up --build # build images and start everything docker compose ps # check status and health docker compose logs -f backend # follow one service's logs docker compose down # stop and remove containers and network 

Open http://localhost:8080 in your browser.
docker compose down keeps the postgres_data volume. docker compose down -v deletes it along with your database. That one flag accounts for a lot of "my data disappeared" posts.

Common problems and how to fix them

The backend can't connect to PostgreSQL
Check that DATABASE_URL uses db as the host, not localhost. Then run docker compose logs db. A wrong password or database name shows up there straight away. If you changed POSTGRES_PASSWORD after the first run, the old password is still in effect, because PostgreSQL only reads those variables when the volume is empty.

A container exits immediately
docker compose ps shows the exit code and docker compose logs shows why. The usual causes are a missing dependency, a typo in CMD, or a crash on startup because an environment variable isn't set.
Other containers can't reach the backend
Your server is probably listening on 127.0.0.1. Bind it to 0.0.0.0.

de changes don't show up
Your code is baked into the image at build time, so run docker compose up --build after changes. For day-to-day development, look into docker compose watch or a bind mount so you don't rebuild on every save.

The frontend gets 404 or CORS errors calling the API
Work out where the request comes from. Calls from the browser need the /api proxy above. Calls between containers use service names. Mixing the two up causes most of these errors.

Database data disappeared
Check whether someone ran down -v, or whether the volume name changed. Renaming the project folder changes the default project name, and with it the volume name.

Quick answers

Do I need Kubernetes to run a multi-tier app in containers?

No. Docker Compose handles a frontend, backend and database on one machine, which covers local development, demos and plenty of small deployments. Kubernetes is for running and scaling containers across many machines. Learn Compose first.

Should the database run in Docker in production?

It can, with a persistent volume, backups and monitoring. Many teams use a managed database service in production instead and keep the PostgreSQL container for local development, which gives every developer the same version without installing anything.

What's the difference between EXPOSE and ports?

EXPOSE in a Dockerfile only documents which port the app uses. ports in Compose actually publishes a container port on your machine. Containers on the same Compose network can reach each other either way.

Where to go from here

Get this three-container setup running, break it on purpose (stop the database, use a wrong hostname, delete the volume), and read the logs each time. Docker makes far more sense once you've debugged it than when you've only read about it.

Solitaire Infosystems is an IT training and internship company in Mohali, and Docker setups like this one come up regularly in the projects learners build during our 6-month industrial training and internship programmes.You can see the current programmes at Solitaire Infosystems

Over to you: what tripped you up most when you started with Docker: networking, Dockerfiles, volumes, or reading container logs?