The surprising truth about containerizing Express apps for production is that the most common Dockerfile for development often actively harms production performance and security.
Let’s see what a basic Express app looks like running inside Docker.
// server.js
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});
And here’s a typical Dockerfile you might see for development:
# Dockerfile (Development)
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
This setup is great for quickly iterating: you can docker build -t my-app-dev . and then docker run -p 3000:3000 my-app-dev. Changes to your code are usually handled by nodemon or similar, which watches files and restarts the server.
Now, let’s build a mental model for production. What problem does this solve? We want to package our application and its dependencies into a portable, isolated unit that can run consistently across different environments. This means:
- Consistency: The app runs the same way on your laptop, staging, and production servers.
- Isolation: The app doesn’t interfere with other applications or the host system, and vice-versa.
- Scalability: Containers are designed to be easily replicated and managed.
- Reproducibility: You can reliably rebuild the exact same environment.
How does it work internally? Docker builds an image layer by layer. Each RUN, COPY, or ADD instruction creates a new layer. When you run a container, Docker starts a new instance of this image, creating a writable layer on top.
The critical levers you control are the Dockerfile instructions. FROM sets the base OS. WORKDIR defines the current directory for subsequent commands. COPY and ADD bring your code and dependencies into the image. RUN executes commands during the build process (like npm install). EXPOSE documents which ports the container listens on. CMD specifies the default command to run when the container starts.
For production, we need to optimize this. The development Dockerfile has several issues for a production-grade deployment:
- Large Image Size:
npm installoften installs development dependencies (devDependencies) that aren’t needed at runtime. This bloats the image. - Security Risks: Installing everything in one go can leave vulnerabilities if
npm installpulls in malicious packages. It also means the image contains build tools and dev dependencies that could be exploited. - Inefficient Layer Caching:
COPY . .invalidates the cache for all subsequent layers if any file changes, even if onlypackage.jsonchanged. This makes builds slower than they need to be. - Unnecessary Processes: Development servers often include hot-reloading or debugging tools that are resource-intensive and insecure in production.
Here’s a production-ready Dockerfile:
# Dockerfile (Production)
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
# Copy only package files first to leverage Docker cache
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy the rest of the application code
COPY . .
# Stage 2: Production Image
FROM node:18-alpine
WORKDIR /app
# Copy only necessary artifacts from the builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app .
# Ensure the app runs as a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
# Use the Node.js executable directly for efficiency and security
CMD ["node", "server.js"]
What’s happening here? We’re using multi-stage builds. The builder stage installs dependencies and potentially builds your app if it requires a compilation step (like TypeScript). Then, the final stage copies only the necessary production artifacts (node_modules, package.json, and your code) into a fresh, minimal Node.js image. We’re also using npm ci --only=production which is faster and more secure than npm install as it installs exact versions from package-lock.json and skips dev dependencies. Finally, we switch to a non-root user (appuser) which is a critical security best practice. CMD ["node", "server.js"] directly executes your server, bypassing any development wrappers.
The most common mistake people make is not understanding that npm install on its own installs all dependencies, including devDependencies. This bloats your production image with packages like nodemon, jest, webpack, etc., which are not needed for your Express app to simply serve requests. Using npm ci --only=production and a multi-stage build ensures only production-ready code and its specific runtime dependencies are included.
This production image will be significantly smaller, more secure, and faster to build after initial setup due to better cache utilization.
The next hurdle you’ll face is managing environment-specific configurations for your containerized application.