Containerizing a Web App
This is the capstone for everything you have learned about containers. We will take a real web app, write a Dockerfile (the recipe Docker uses to build an image), build that image, run it with the right ports and environment variables, and finally wire it up to a PostgreSQL database using Docker Compose (a tool that runs several containers together from one file). By the end you will have a running app talking to a running database, all defined in code you can commit to git. This is exactly how teams ship software in 2026.
We will use a small Node.js + Express app as our example, but the pattern is identical for Python, Go, Ruby, or Java. The point is the workflow, not the language.
The app we are containerizing
Imagine a folder on your Ubuntu machine with a tiny web app. It listens on a port, reads its configuration from environment variables (settings passed in from outside the app), and connects to a database.
mkdir -p ~/myapp && cd ~/myapp
ls -la
The project has these files:
package.json
package-lock.json
server.js
A trimmed server.js shows how it reads config from the environment — this is the key habit that makes an app “container-ready”:
const express = require("express");
const { Pool } = require("pg");
const app = express();
const port = process.env.PORT || 3000;
// Database connection comes entirely from env vars — never hardcoded.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get("/", async (req, res) => {
const { rows } = await pool.query("SELECT NOW()");
res.json({ status: "ok", dbTime: rows[0].now });
});
app.listen(port, () => console.log(`Listening on ${port}`));
Notice that nothing about the database is written into the code. The app reads PORT and DATABASE_URL from the environment. When to do this: always. An app that hardcodes its config cannot be safely containerized, because the same image must run unchanged in development, staging, and production with only the env values changing.
Writing the Dockerfile
A Dockerfile is a plain text file named exactly Dockerfile (no extension) that lists, step by step, how to build your image. Each instruction creates a cached layer, so ordering matters for build speed.
We will use a multi-stage build (a Dockerfile with more than one FROM, where one stage builds and a later stage only keeps what is needed). This keeps the final image small and free of build tooling.
# ---- Stage 1: install dependencies ----
FROM node:22-slim AS deps
WORKDIR /app
# Copy only the manifests first so this layer is cached
# until your dependencies actually change.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- Stage 2: final runtime image ----
FROM node:22-slim
WORKDIR /app
# Run as a non-root user that the node image already provides.
USER node
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=node:node . .
EXPOSE 3000
CMD ["node", "server.js"]
A few choices worth understanding:
| Instruction | Why it is written this way |
|---|---|
node:22-slim | A small official base image. slim drops extra OS packages you do not need. |
| Copy manifests before code | Dependency installs are cached unless package.json changes — much faster rebuilds. |
npm ci not npm install | ci installs the exact locked versions, giving reproducible builds. |
USER node | Runs the app as a non-root user, so a break-in is far less dangerous. |
EXPOSE 3000 | Documents the port the app listens on (it does not actually publish it). |
Never run your container as root if you can avoid it. If an attacker breaks out of a process running as root, they may gain root on related resources. The
USER nodeline is one of the cheapest security wins you can make.
Adding a .dockerignore
The .dockerignore file tells Docker which files to leave out of the build. Without it, Docker copies your entire folder — including node_modules, secrets, and git history — into the build, making images huge and leaky.
# ~/myapp/.dockerignore
node_modules
npm-debug.log
.git
.env
Dockerfile
.dockerignore
*.md
When to use this: every single project. Excluding node_modules matters because we reinstall them cleanly inside the image, and excluding .env keeps real secrets out of the image entirely.
Building the image
From inside ~/myapp, build the image and give it a name and tag (a label, here 1.0):
docker build -t myapp:1.0 .
Output:
[+] Building 14.2s (13/13) FINISHED
=> [deps 3/4] COPY package.json package-lock.json ./ 0.1s
=> [deps 4/4] RUN npm ci --omit=dev 9.8s
=> [stage-1 4/5] COPY --from=deps /app/node_modules ... 0.4s
=> exporting to image 0.6s
=> => naming to docker.io/library/myapp:1.0
Confirm the image exists and check its size:
docker images myapp
Output:
REPOSITORY TAG IMAGE ID CREATED SIZE
myapp 1.0 8f2c1a9b7d44 12 seconds ago 198MB
Running the container with env and ports
You can run the image on its own, passing the port mapping and environment variables on the command line. The -p 8080:3000 means “publish host port 8080 to container port 3000” (host first, container second).
docker run --rm -p 8080:3000 \
-e PORT=3000 \
-e DATABASE_URL="postgres://app:secret@localhost:5432/appdb" \
myapp:1.0
The --rm flag deletes the container when it stops, so you do not accumulate dead containers. But this app needs a database, and localhost inside a container means the container itself, not your host. This is exactly the problem Compose solves.
Connecting a database with Compose
Docker Compose lets you describe the app and its database in one docker-compose.yml file and start them together with one command. Compose creates a private network so containers can reach each other by service name.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
web:
build: .
ports:
- "8080:3000"
environment:
PORT: 3000
# 'db' is the service name — Compose resolves it to the db container.
DATABASE_URL: postgres://app:secret@db:5432/appdb
depends_on:
db:
condition: service_healthy
The important detail is @db:5432 in the connection string. Inside the Compose network, the hostname db automatically points at the database container. The depends_on with service_healthy makes the web app wait until PostgreSQL is actually accepting connections, not just started. The named volume dbdata keeps your database data alive even if you destroy and recreate the container.
Start everything:
docker compose up --build
Output:
[+] Running 3/3
✔ Network myapp_default Created
✔ Container myapp-db-1 Healthy
✔ Container myapp-web-1 Started
myapp-web-1 | Listening on 3000
Test it from another terminal:
curl http://localhost:8080/
Output:
{"status":"ok","dbTime":"2026-06-15T10:42:08.137Z"}
The app is talking to PostgreSQL. When you are done, tear it down — add -v to also delete the volume if you want a clean slate:
docker compose down
Never bake real passwords into
docker-compose.ymlthat you commit to git. For anything beyond local development, load secrets from a.envfile (kept out of git) or your platform’s secrets manager. Compose automatically reads a.envfile in the same folder.
Best Practices
- Read all configuration (ports, database URLs, API keys) from environment variables — never hardcode them in the image.
- Always add a
.dockerignoreso you do not shipnode_modules,.git, or.envinto your image. - Use multi-stage builds and
slimbase images to keep images small and reduce attack surface. - Run containers as a non-root user with a
USERinstruction. - Copy dependency manifests before source code so dependency layers stay cached across rebuilds.
- Use
depends_onwith healthchecks so your app waits for the database instead of crash-looping on startup. - Pin image tags (for example
postgres:16, notpostgres:latest) so builds stay reproducible.