Docker and Containerization for Modern Web Development
Learn to containerize your applications using Docker for consistent deployments.

The first time Docker prints “container is running,” something quiet clicks into place — your app no longer depends on which machine it’s on. This guide is the short, opinionated version of what to know to make containers part of your everyday toolkit, not a once-a-quarter ritual.
What is Docker?
Docker packages your application together with its operating system dependencies into an image: a versioned, immutable snapshot. A container is a running instance of that image — a process tree isolated from the host with namespaces and cgroups.
The mental model that has aged well:
- Image — a recipe.
- Container — a meal cooked from the recipe.
- Volume — the pantry that survives the meal.
- Network — how dishes pass between kitchens.
If you can think in those four nouns, the rest of Docker becomes mostly grammar.
Containerization Benefits
- Same artifact, every environment. What you tested in CI is what runs in prod.
- Fast onboarding. New teammates run one command and have a working stack.
- Clean rollbacks. Tag the image, pin the deploy, roll back by tag.
- Density. Containers share the host kernel, so you can pack many services on one VM.
The trade-off is real: another layer to debug, another file to maintain, another security boundary to think about. Earn the complexity by needing it.
Dockerfile Basics
A pragmatic Dockerfile for an Astro app built with the Node adapter (output: "server", standalone mode) — multi-stage, slim runtime, sensible defaults:
# syntax=docker/dockerfile:1.7
# 1. deps — install only production dependencies in a cacheable layer
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# 2. build — compile the application
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
# 3. runtime — minimal image with only what we need to run
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
EXPOSE 4321
USER node
CMD ["node", "./dist/server/entry.mjs"]
For a purely static Astro site (the default output: "static"), skip Node at runtime entirely — copy dist/ into an nginx:alpine image and serve it.
Three habits worth keeping forever:
- Use a
.dockerignore— excludingnode_modules,.git, anddistshaves seconds off every build. - Order layers by churn — least-changing first, most-changing last.
- Always set a non-root
USERin the runtime stage.
Docker Compose
Compose is for development environments and small single-host deployments. The good kind of YAML:
services:
web:
build: .
ports:
- "4321:4321"
environment:
DATABASE_URL: postgres://app:app@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
# bring everything up
docker compose up --build
# tail logs from one service
docker compose logs -f web
# rebuild a single service
docker compose up -d --build web
Image Optimization
Smaller images deploy faster, scan cleaner, and cost less to store.
- Start from
alpineor distroless when you can. - Use multi-stage builds to leave compilers and headers behind.
- Pin versions —
node:20-alpine, notnode:latest. dive image-nameto see exactly what each layer is paying for.
# inspect layer-by-layer cost
brew install dive
dive my-app:latest
Security Best Practices
Containers are not a security boundary on their own — they’re a shape you can put a security boundary around.
- Run as a non-root user.
- Scan images on every push (
trivy image my-app:latest). - Pin all base images by digest in production.
- Set read-only root filesystems where possible.
- Drop Linux capabilities you don’t need.
services:
web:
image: my-app:abc123
read_only: true
tmpfs:
- /tmp
cap_drop: ["ALL"]

Production Deployment
For low-traffic services, docker compose on a single VM is fine. As you grow:
- Fly.io / Render / Railway — push a Dockerfile, get a URL.
- ECS / Cloud Run — managed, scales to zero, handles certs.
- Kubernetes — when you’ve earned the operational headcount.
Whatever the runtime, the deploy command should look the same in CI:
docker build -t registry.example.com/app:$(git rev-parse --short HEAD) .
docker push registry.example.com/app:$(git rev-parse --short HEAD)
The deploy is just “use this tag.” That’s the whole point.
Closing Thoughts
Treat your Dockerfile like production code: review it, version it, retire it when something simpler arrives. The best container setup is the one your team forgets is there — until the day it saves them from a frantic Friday rebuild.
About the author
The Plake Team
Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.
Read moreKeep reading









