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:
- Traefik integration: This connects your Docker container to a reverse proxy, handling HTTPS automatically
- Restart policy:
unless-stoppedmeans the service restarts if it crashes - Custom network: The
webnetwork connects multiple services, letting Traefik route traffic intelligently
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:
- Volume mounts: Your local code is mounted into the container, so changes instantly reflect without rebuilding
- Delegated mode: Tells Docker to optimize file sync for performance
- Node modules volume: Keeps dependencies isolated from your host machine
- Port 8081: Maps to your dev server's internal port 8080
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:
- Node builds your site into the
/distfolder - Nginx picks up those static files
- Traefik routes incoming requests to your container
- 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:
- Edit files in your local editor
- See changes instantly in the browser
- Run
npm installif you add dependencies - Test your site exactly as it'll appear in production
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
-
Always use
npm ciin Dockerfiles, notnpm install. It's deterministic and prevents version drift. -
Keep your
.envfile secure. Don't commit it to version control if it contains sensitive information. -
The delegated volume mount on dev is important for performance on macOS and Windows. It tells Docker "don't sync every file instantly."
-
Traefik expects an external network. Make sure you've created it:
docker network create web -
When rebuilding production, use
--buildto 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.