“It works on my machine” is the problem Docker was built to solve. A container packages your application together with its runtime and dependencies, so it behaves the same on a laptop, a CI runner and a production server. This guide takes you from your first docker run to an image you would be comfortable deploying.
Images and containers in one sentence each
- An image is a read-only template: a filesystem plus metadata such as the default command.
- A container is a running instance of an image, with its own isolated process space, network and writable layer.
You build images once and run many containers from them.
Your first container
After installing Docker Desktop (or Docker Engine on Linux), run:
docker run --rm -p 8080:80 nginx:1.27-alpine
Open http://localhost:8080 and you will see the Nginx welcome page. Breaking the command down:
--rmremoves the container when it stops.-p 8080:80maps port 8080 on your machine to port 80 inside the container.nginx:1.27-alpineis the image name and a specific tag. Pinning a tag avoids surprises whenlatestchanges.
Useful companions: docker ps lists running containers, docker logs <id> shows output and docker exec -it <id> sh opens a shell inside a container.
Writing a Dockerfile
Here is a Dockerfile for a small Node.js API. It uses a multi-stage build: the first stage installs dependencies and builds; the final stage contains only what is needed to run.
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Build and run it:
docker build -t my-api:1.0.0 .
docker run --rm -p 3000:3000 my-api:1.0.0
Why the order of instructions matters
Docker caches each instruction as a layer. Copying package*.json and installing dependencies before copying the rest of the source means that editing a source file does not invalidate the dependency layer. Rebuilds become much faster.
Add a .dockerignore file so local clutter never reaches the image:
node_modules
.git
.env
dist
coverage
Keeping data: volumes
Containers are disposable. Anything written inside a container’s filesystem disappears when the container is removed. Databases and uploads need volumes:
docker volume create pgdata
docker run -d --name db -e POSTGRES_PASSWORD=devpass \
-v pgdata:/var/lib/postgresql/data postgres:16
In development you can also bind-mount your source directory so changes appear instantly, but avoid bind mounts in production.
Multiple services with Docker Compose
Real applications are rarely one container. Compose describes several services in one file:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:devpass@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
volumes:
pgdata:
docker compose up -d starts everything; services reach each other by service name (db), because Compose creates a private network.
The passwords above are for local development only. In production, inject secrets from your platform’s secret manager — never bake them into images or commit them to Git.
From laptop to production: the habits that matter
- Pin versions of base images and rebuild regularly to pick up security patches.
- Run as a non-root user (
USER nodeabove) to limit the impact of a compromise. - Keep images small: alpine or slim bases, multi-stage builds, no build tools in the final stage.
- One process per container, logging to stdout/stderr so the platform can collect logs.
- Add health checks so orchestrators can restart unhealthy containers.
- Scan images for known vulnerabilities in CI before pushing to a registry.
- Tag images immutably (for example with the Git commit SHA) so every deployment is traceable.
Debugging containers: a short toolkit
When something does not work, these commands answer most questions:
docker ps -a # is the container running, or did it exit?
docker logs -f api # what did the process print?
docker inspect api | less # env vars, mounts, network settings
docker exec -it api sh # look around inside the container
docker stats # live CPU and memory usage
docker image history my-api:1.0.0 # which layers make the image large?
A container that exits immediately usually means the main process crashed or finished — read the logs first. “Connection refused” between services often means you used localhost instead of the service name: inside a container, localhost is the container itself, not your machine.
Finally, clean up occasionally. Unused images, stopped containers and build cache can take gigabytes of disk space. docker system df shows usage and docker system prune removes unused data (read its prompt carefully before confirming).
Where to go next
Once you are comfortable with images and Compose, the natural next steps are pushing images to a registry from CI, and running them on a managed platform or an orchestrator such as Kubernetes. The fundamentals you learned here — layers, volumes, networks and small, non-root images — carry over directly.
Comments