Containerizing Your Digital Garden With Docker

Docker makes deploying applications consistent and hassle-free, but the configuration can feel intimidating at first.

If you're running 11ty (Eleventy) a Node.js-based static site generator (like a Digital Garden), here's a practical walkthrough of how to set up Docker for both local development and production deployment.

The Production Dockerfile: Building Lean and Mean

Stage 1: Building Your Site

The first part of the Dockerfile focuses on compiling your application:

FROM node:lts-alpine AS builder
WORKDIR /usr/src/app

ARG THEME
ENV THEME=${THEME}
# ... more build arguments ...

This starts with a lightweight Node.js Alpine image (Alpine Linux is tiny—perfect for build stages). The ARG declarations define build-time variables like theme selection and feature toggles. These get passed in from Docker Compose, letting you customize the build without changing code.

The build process itself is straightforward:

COPY package.json package-lock.json* ./
RUN npm ci --silent

COPY . .
RUN npm run build

Key detail: Using npm ci instead of npm install ensures reproducible, deterministic builds. It installs exact versions from the lock file, preventing surprises between environments.

Stage 2: Serving with Nginx

Here's where the magic happens:

FROM nginx:alpine AS runner
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Only the built static files get copied to the production image. The Node.js build tools, source code, and dependencies are left behind. Your final image is tiny and fast—perfect for production.

This is full version of Dockerfile:

# Multi-stage Dockerfile: build static site with Node, serve with Apache httpd
FROM node:lts-alpine AS builder
WORKDIR /usr/src/app

# Allow passing THEME at build time so get-theme can fetch the theme CSS
ARG THEME
ENV THEME=${THEME}
ARG dgHomeLink
ARG dgShowBacklinks
ARG dgShowLocalGraph
ARG dgShowInlineTitle
ARG dgShowFileTree
ARG dgEnableSearch
ARG dgShowToc
ARG dgLinkPreview
ARG dgShowTags
ARG BASE_THEME

ENV dgHomeLink=${dgHomeLink}
ENV dgShowBacklinks=${dgShowBacklinks}
ENV dgShowLocalGraph=${dgShowLocalGraph}
ENV dgShowInlineTitle=${dgShowInlineTitle}
ENV dgShowFileTree=${dgShowFileTree}
ENV dgEnableSearch=${dgEnableSearch}
ENV dgShowToc=${dgShowToc}
ENV dgLinkPreview=${dgLinkPreview}
ENV dgShowTags=${dgShowTags}
ENV BASE_THEME=${BASE_THEME}

# Install build tools and dependencies (use ci for deterministic install)
COPY package.json package-lock.json* ./
RUN npm ci --silent

# Copy sources and build
COPY . .
RUN npm run build

# Production image: serve built files with Apache httpd
FROM nginx:alpine AS runner
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html
  
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Docker Compose: Orchestrating Everything

The docker-compose.yml defines two services: one for production and one for development. This is brilliant because the same file handles both workflows.

Production Service (web)

services:
  web:
    build:
      context: .
      args:
        THEME: ${THEME}
        dgHomeLink: ${dgHomeLink}
        # ... more build args ...
    image: ivan-digitalgarden-web:latest

The build section passes environment variables as build arguments, customizing how your site renders. Want to change your theme? Update the .env file and rebuild.

The key production features:

Notice the ports are commented out. That's intentional—in production with Traefik, you don't expose ports directly. Instead, Traefik handles routing to your container internally.

Development Service (dev)

dev:
    image: node:22
    working_dir: /usr/src/app
    volumes:
      - ./:/usr/src/app:delegated
      - /usr/src/app/node_modules
    command: sh -c "npm install --no-audit --no-fund && npm run dev"
    ports:
      - "8081:8080"

This is a live development environment. Here's what makes it work:

Run docker compose up -d dev, then visit localhost:8081 and start coding. Every file save triggers hot-reload.

This is full version of docker-compose.yaml

services:
  web:
    build:
      context: .
      args:
        THEME: ${THEME}
        dgHomeLink: ${dgHomeLink}
        dgShowBacklinks: ${dgShowBacklinks}
        dgShowLocalGraph: ${dgShowLocalGraph}
        dgShowInlineTitle: ${dgShowInlineTitle}
        dgShowFileTree: ${dgShowFileTree}
        dgEnableSearch: ${dgEnableSearch}
        dgShowToc: ${dgShowToc}
        dgLinkPreview: ${dgLinkPreview}
        dgShowTags: ${dgShowTags}
        BASE_THEME: ${BASE_THEME}
    image: ivan-digitalgarden-web:latest
    # ports:
    #   - "8080:80"
    environment:
      - NODE_ENV=production
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.web.rule=Host(`ivan.click`) || Host(`www.ivan.click`)"
      - "traefik.http.routers.web.entrypoints=websecure"
      - "traefik.http.routers.web.tls.certresolver=myresolver"
      - "traefik.http.services.web.loadbalancer.server.port=80"
      - "traefik.http.middlewares.web-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.routers.web.middlewares=web-headers"
      - "traefik.port=80"
    networks:
      - web
    restart: unless-stopped

  dev:
    image: node:24
    working_dir: /usr/src/app
    volumes:
      - ./:/usr/src/app:delegated
      - /usr/src/app/node_modules
    command: sh -c "npm install --no-audit --no-fund && npm run dev"
    environment:
      - NODE_ENV=development
    ports:
      - "8081:8080"
    restart: unless-stopped

networks:
  web:
    external: true

Putting It Into Action

Deploy to Production

docker compse up -d --build web

This builds the production image and starts the web service. The --build flag ensures you're using the latest code. Docker Compose uses your .env file for all those theme and feature variables automatically.

Behind the scenes:

  1. Node builds your site into the /dist folder
  2. Nginx picks up those static files
  3. Traefik routes incoming requests to your container
  4. HTTPS is handled automatically (thanks to Traefik's certificate resolver)

Start Developing Locally

docker compose up -d dev

This boots up a development environment where you can:

No installing Node.js locally, no managing versions, no "works on my machine" problems.

Why This Architecture Is Smart

Aspect Benefit
Multi-stage builds Production image stays lean; build tools don't ship to the server
Alpine Linux Tiny base images mean faster pulls and smaller resource usage
Build arguments Customize the site at build time without changing Dockerfile
Docker Compose One configuration file, two workflows—dev and production use the same setup
Volume mounts Developers work locally but run in an exact replica of production
Traefik integration HTTPS, routing, and certificate management handled automatically

Environment Variables: Controlling Your Build

Both services reference variables like THEME, dgHomeLink, and BASE_THEME. These come from a .env file:

THEME=darkdgHome
Link=true
dgShowBacklinks=true
dgShowLocalGraph=true
dgShowInlineTitle=true
dgShowFileTree=true
dgEnableSearch=true
dgShowToc=true
dgLinkPreview=true
dgShowTags=true
BASE_THEME=dark

Change these, rebuild, and your site instantly reflects the new configuration. No hardcoding, no touching the Dockerfile.

Common Workflows

You want to update the design

# Edit your .env
THEME=light

# Rebuild and deploy
docker compose up -d --build web

You're developing a new feature

# Start the dev environment
docker compose up -d dev

# Edit files locally, see changes at localhost:8081

# When satisfied, commit and push
git commit -am "Add new feature"

You need to debug production

# Check logs
docker compose logs web

# Restart the service
docker compose restart web

A Few Tips for Success

  1. Always use npm ci in Dockerfiles, not npm install. It's deterministic and prevents version drift.

  2. Keep your .env file secure. Don't commit it to version control if it contains sensitive information.

  3. The delegated volume mount on dev is important for performance on macOS and Windows. It tells Docker "don't sync every file instantly."

  4. Traefik expects an external network. Make sure you've created it: docker network create web

  5. When rebuilding production, use --build to ensure you're not accidentally using a stale image.

Why Docker Matters for Your Workflow

Without Docker, you'd need to install Node.js, manage versions, configure Nginx, set up HTTPS certificates, and hope everything works the same on your server as your laptop. With this setup, everything is consistent, reproducible, and documented in code. A new developer can run two commands and have an identical environment. Deploying is automated and reliable. That's the real win.