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=devinstead ofnpm install— it installs exact versions frompackage-lock.jsonand skips dev dependencies, giving you reproducible, smaller images.USER node— the official Node image ships a non-rootnodeuser. 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.0names 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
-druns detached (in the background)--name my-apigives the container a readable name-p 3000:3000maps 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
- Verified the app runs natively before containerizing
- Wrote a Dockerfile with cache-friendly layer ordering,
npm ci, and a non-root user - Used
.dockerignoreto keep secrets and junk out of the image - Built, ran, and tested the container locally
- 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.