Complete Docker deployment guide for websites: from development to production

"Runs fine on my machine, why does it break in production?" — almost anyone who has deployed a website by hand has heard this. The problem is rarely the code itself; it's the environment: your local Node version, system libraries, and database configuration never match the server's. Docker's answer is to turn the runtime environment itself into code — committed to the repo, reproducible anywhere, and rollback-able at any time. Containerization transforms website deployment from manual configuration into code-managed processes. This guide skips the theory and walks through a complete frontend + backend + database project, from Dockerfile to production.

Project structure

A typical containerized website project looks like this:

project/
├── frontend/       # Frontend app (Nginx + static files)
├── backend/        # Backend API (Node.js/Python/Go)
├── docker-compose.yml
└── .env

Split the code into frontend and backend directories, each with its own Dockerfile, so the frontend build, backend service, and database can be scaled and updated independently. The .env file holds database passwords and secrets, and is excluded via .gitignore so credentials never reach the repository.

Frontend Dockerfile: multi-stage builds

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Multi-stage builds are the key to a slim frontend image. The first builder stage installs dependencies and runs npm run build; the second nginx:alpine stage only copies the build output. The final image contains neither source code nor node_modules, shrinking from 1GB+ to a few dozen MB — and fewer dependencies means a smaller attack surface. Prefer npm ci over npm install in CI: it installs strictly from package-lock.json, so local and production dependencies stay identical. That same image also doubles as a local dev environment, saving the time spent re-setting up tooling.

Backend Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

The production backend image doesn't need devDependencies, so npm ci --production installs only runtime packages. Using Python instead? Swap that line for RUN pip install --no-cache-dir -r requirements.txt — same idea: pin versions, install only production deps, and run as a non-root user (add USER node to the Dockerfile).

Also add a .dockerignore that excludes node_modules, dist, .git, and *.log from the build context. It serves two purposes: it stops local dependencies from being copied into the image by accident, and it dramatically shrinks the data sent to the Docker daemon, speeding up builds.

Docker Compose orchestration

version: "3.8"
services:
  nginx:
    build: ./frontend
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - backend

  backend:
    build: ./backend
    env_file: .env
    environment:
      - NODE_ENV=production
    restart: always

  postgres:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    env_file: .env

volumes:
  pgdata:

Compose ties the three services into one "virtual server." A few details matter: the named volume pgdata: persists the database, so data survives container recreation; env_file: .env injects environment variables uniformly; restart: always brings the process back up after crashes or server reboots. Once the frontend and backend share the same network, Nginx can reach the backend simply as backend:3000 — no container IP guessing.

Configure a reverse proxy

server {
    listen 80;
    server_name example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        proxy_pass http://backend:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /static/ {
        alias /usr/share/nginx/html/static/;
        expires 365d;
    }
}

Port 80 permanently 301-redirects to HTTPS, static assets get expires 365d for browser caching, and dynamic requests proxy to the backend. Renew SSL certificates automatically with certbot certonly --webroot -w /usr/share/nginx/html -d example.com, then mount the cert directory into the container.

Wire up CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and push Docker image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker push myapp:${{ github.sha }}
      - name: Deploy to server
        run: |
          ssh user@server "cd /app && docker-compose pull && docker-compose up -d"

CI/CD turns "build → push → deploy" into a pipeline that runs on every git push. Tag images with the commit SHA; to roll back, point docker-compose up -d at the previous tag and you're live again in seconds.

Health checks and graceful shutdown

In production you can't just check "the container started" — you need to confirm the service is actually usable. Add a health endpoint to the backend and declare it in Compose:

backend:
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
    interval: 30s
    timeout: 5s
    retries: 3

Nginx's depends_on only waits for the container to start; combined with a healthcheck it waits for the service to be ready. restart: always plus health checks lets a crashed service come back automatically instead of forwarding requests to a half-dead backend.

Environment variables and secrets

env_file: .env solves the "no config baked into the image" problem: the image never hardcodes the database password, and values are injected at runtime from environment variables. Practical rules: never commit .env to Git; keep a separate .env per environment (dev/staging/prod); never write secrets in plain text inside docker-compose.yml. For stricter requirements, move to Docker secrets or a cloud key-management service. Taken together, these practices make images portable, config replaceable, and secrets auditable.

Troubleshooting

  • Container exits immediately: check docker logs <container>, then confirm the CMD runs in the foreground. Nginx needs daemon off; or the container will exit at once.
  • Port conflicts: inspect with docker ps or change the host-side port on the left of ports: in Compose.
  • Lost data: make sure the database uses a named volume — never store important data inside a container.
  • Slow builds: COPY package*.json first and run npm ci before copying the rest, so Docker layer caching skips dependency re-installs on source changes.

16IDC Takeaway

Docker deployment greatly reduces environment inconsistency issues. New projects should use Docker and Compose from the start — even simple static sites benefit from cleaner deployment and rollback processes. Deployment in the container era isn't about a single script — it's about a repeatable build-orchestrate-monitor pipeline. Once images are ready, you still need a suitable server to host them; check our server recommendations.

References: Docker multi-stage builds https://docs.docker.com/build/building/multi-stage/; Docker Compose https://docs.docker.com/compose/