Skip to main content

Hands-On: Containerize an Express App

What We Are Building

You already know what images, containers, and Dockerfiles are. In this walkthrough we take a real Express app from source code to a published image on Docker Hub — the exact flow you'd use before deploying to any server.

Step 1: Scaffold the Express App

Create a minimal but realistic API:

mkdir express-docker && cd express-docker
npm init -y
npm install express

Create index.js:

const express = require('express');
const app = express();

const PORT = process.env.PORT || 3000;

app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});

app.get('/', (req, res) => {
res.json({ message: 'Hello from inside a container' });
});

app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});

Add a start script to package.json:

{
"scripts": {
"start": "node index.js"
}
}

Run npm start and hit http://localhost:3000/health to confirm it works before involving Docker. I always verify the app runs natively first — it saves you from debugging two problems at once.

Step 2: Write the Dockerfile

Create a file named Dockerfile (no extension) in the project root:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000

USER node

CMD ["node", "index.js"]

Two choices here matter in production:

  • npm ci --omit=dev instead of npm install — it installs exact versions from package-lock.json and skips dev dependencies, giving you reproducible, smaller images.
  • USER node — the official Node image ships a non-root node user. Running as root inside a container is an unnecessary risk.

Copying package*.json before the rest of the source is deliberate: dependency layers only rebuild when the lock file changes, so day-to-day code edits reuse the cached npm ci layer and builds stay fast.

Step 3: Add a dockerignore File

Create .dockerignore in the project root:

node_modules
npm-debug.log
.git
.gitignore
.env
Dockerfile
.dockerignore
README.md

Without this, COPY . . would ship your local node_modules (built for your OS, not Alpine Linux) and your .env secrets into the image. Excluding node_modules also keeps the build context small, which speeds up every build.

Step 4: Build the Image

docker build -t express-docker:1.0.0 .
  • -t express-docker:1.0.0 names the image and gives it a version tag
  • . is the build context (the current directory)

Confirm it exists:

docker images express-docker

You should see the image at roughly 130 MB — that's the Alpine base plus your app.

Step 5: Run the Container

docker run -d --name my-api -p 3000:3000 express-docker:1.0.0
  • -d runs detached (in the background)
  • --name my-api gives the container a readable name
  • -p 3000:3000 maps host port 3000 to container port 3000

Test it:

curl http://localhost:3000/health
# {"status":"ok","uptime":4.21}

Check the logs and inspect the running process:

docker logs -f my-api # stream stdout (Ctrl+C to stop)
docker exec -it my-api sh # open a shell inside the container
docker stats my-api # live CPU and memory usage

To pass environment variables at runtime instead of baking them in:

docker run -d --name my-api-8080 -p 8080:8080 -e PORT=8080 express-docker:1.0.0
curl http://localhost:8080/health

Step 6: Tag the Image for Docker Hub

Docker Hub images are named username/repository:tag. Retag your local image with your Docker Hub username:

docker tag express-docker:1.0.0 yourusername/express-docker:1.0.0
docker tag express-docker:1.0.0 yourusername/express-docker:latest

Tagging both a version and latest is a common convention: servers can pin 1.0.0 while quick experiments pull latest. A tag is just a pointer — no data is copied.

Step 7: Push to Docker Hub

Log in, then push:

docker login
docker push yourusername/express-docker:1.0.0
docker push yourusername/express-docker:latest

Now any machine with Docker can run your app with a single command:

docker run -d -p 3000:3000 yourusername/express-docker:1.0.0

That's the payoff — no Node installation, no npm install, no version mismatches on the server.

Step 8: Clean Up

docker stop my-api my-api-8080
docker rm my-api my-api-8080
docker image prune # remove dangling layers

Recap

  1. Verified the app runs natively before containerizing
  2. Wrote a Dockerfile with cache-friendly layer ordering, npm ci, and a non-root user
  3. Used .dockerignore to keep secrets and junk out of the image
  4. Built, ran, and tested the container locally
  5. Tagged with a version number and pushed to Docker Hub

This image is now ready to be pulled onto any server, or referenced from a Compose file as image: yourusername/express-docker:1.0.0.