Containers bundle your app with the exact runtime and dependencies it needs, so it behaves the same on your laptop, in CI and in production. Here is a simple, solid setup for a Node.js API.
Images, containers and layers in one minute
Three words come up constantly. An image is a read-only package: an operating system base, Node.js, your dependencies and your code. A container is a running copy of an image, with its own processes and network, and you can start many containers from one image. Images are built in layers, one for roughly each instruction in the Dockerfile, and Docker reuses any layer whose inputs haven’t changed. Most of the tricks below are about ordering instructions so that the slow layers are reused as often as possible.
You need Docker Desktop on macOS or Windows, or Docker Engine on Linux. Check it works with docker –version before you start.
1. Add a .dockerignore
This keeps local junk and secrets out of the image and makes builds faster:
node_modules
npm-debug.log
.git
.env
dist
2. Write the Dockerfile
FROM node:24-alpine
WORKDIR /app
# Install dependencies first so Docker can cache this layer
COPY package*.json ./
RUN npm ci --omit=dev
# Copy the rest of the source
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]
- Copying package files before the source means dependencies are only reinstalled when they change.
- npm ci installs exactly what is in package-lock.json.
- Running as the built-in node user avoids running your app as root.
3. Build and run
docker build -t my-api .
docker run -p 3000:3000 --env-file .env my-api
4. Add a database with Docker Compose
Compose starts your API and PostgreSQL together with one command:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/app
depends_on:
- db
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up --build
Inside the Compose network, the API reaches the database at the hostname db, which is the service name. Note that depends_on only waits for the database container to start, not for PostgreSQL to accept connections, so make your app retry its first connection for a few seconds. Compose also supports health checks if you want the API to wait properly.
The named volume pgdata keeps the database files outside the container. You can stop, rebuild or delete the containers and your data survives. Run docker compose down -v only when you really want to wipe it.
5. A multi-stage build for TypeScript
If your app is written in TypeScript, you need the compiler during the build but not at runtime. A multi-stage Dockerfile builds in one stage and copies only the result into a clean final image:
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
USER node
CMD ["node", "dist/server.js"]
The final image has no TypeScript compiler, no dev dependencies and no source files, which makes it smaller and gives attackers less to work with.
Debugging a container
- docker ps lists running containers; docker ps -a includes ones that crashed.
- docker logs followed by the container name shows what your app printed before it died.
- docker exec -it followed by the container name and sh opens a shell inside a running container, handy for checking files and environment variables.
- If the app runs but you can’t reach it, check that it listens on 0.0.0.0 rather than localhost. Inside a container, localhost means the container itself.
Tips
- Never bake secrets into the image. Pass them as environment variables at runtime.
- Pin a major Node.js version in FROM so a new release does not change your runtime unexpectedly.
- Rebuild the image regularly, even when your code hasn’t changed, so it picks up security fixes in the base image.
- Handle the SIGTERM signal in your app and close the server cleanly. Platforms send it before stopping a container, and a graceful shutdown avoids dropped requests during deploys.
What we think
For most Node.js projects, a container is worth the small upfront effort. It ends the “works on my machine” problem, makes onboarding new developers faster and lets you move between hosting providers without rewriting your deployment.
Start with the simple Dockerfile above, add Compose when you need a database, and only reach for multi-stage builds and orchestration when the project actually needs them. A plain, well-ordered Dockerfile you understand beats a clever one you copied.