|

Docker + Nginx for Static Sites (Part 5/7)

Almost there. We’ve got an Astro site pulling from Contentful, building optimized static files. Now let’s package it up and ship it.

I already run my Hugo site with Docker and Nginx, so the pattern is familiar — just swap the build stage from Hugo to Node.js.

The Dockerfile

Two stages: Node builds the site, Nginx serves it. The final image has no Node.js, no node_modules, no source code. Just HTML and Nginx.

# Stage 1: Build
FROM node:22-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --production=false

COPY astro.config.mjs tsconfig.json ./
COPY src ./src
COPY public ./public

ARG CONTENTFUL_SPACE_ID
ARG CONTENTFUL_DELIVERY_TOKEN
ENV CONTENTFUL_SPACE_ID=$CONTENTFUL_SPACE_ID
ENV CONTENTFUL_DELIVERY_TOKEN=$CONTENTFUL_DELIVERY_TOKEN

RUN npm run build

# Stage 2: Serve
FROM nginx:1.27-alpine

RUN rm /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*

COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost/ || exit 1

Compared to my current setup:

  • npm ci instead of apk add hugo. Deterministic installs, no surprises.
  • Build args for Contentful tokens. They only exist in the build stage, which gets discarded.
  • Healthcheck. Something I should have added before. Docker can restart the container if Nginx goes down.
  • Same Nginx Alpine base. Final image stays under 30MB.

Nginx config

This is where I’d upgrade from my current “just serve files” approach to something production-worthy:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; img-src 'self' https://images.ctfassets.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'" always;

    # Gzip
    gzip on;
    gzip_vary on;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types text/plain text/css text/xml text/javascript
               application/javascript application/json application/xml
               application/rss+xml image/svg+xml;

    # Astro hashes filenames — cache forever
    location /_astro/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Fonts and icons
    location ~* \.(ico|svg|woff|woff2|ttf|eot)$ {
        expires 6M;
        add_header Cache-Control "public";
        access_log off;
    }

    # Images
    location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
        expires 1M;
        add_header Cache-Control "public";
        access_log off;
    }

    # Redirect root to default locale
    location = / {
        return 301 /en/;
    }

    # Clean URLs
    location / {
        try_files $uri $uri/ $uri/index.html =404;
    }

    error_page 404 /en/404.html;

    # No hidden files
    location ~ /\. {
        deny all;
        access_log off;
    }
}

The highlights:

Security headers. Prevents clickjacking, MIME sniffing, and restricts what can load on the page. The CSP whitelists Contentful’s image CDN and Google Fonts — adjust to your needs.

Aggressive caching. Astro puts hashed filenames in /_astro/, so those files literally never change. Cache them for a year. Fonts get 6 months. Images get 1 month. HTML gets default behavior (revalidates on each visit).

try_files. When someone visits /en/blog/hello-world, Nginx looks for the file, then the directory, then index.html inside that directory. This is what makes clean URLs work without routing logic.

Docker Compose

Nearly identical to what I have today:

version: "3.9"

services:
  website:
    build:
      context: .
      dockerfile: docker/Dockerfile
      args:
        CONTENTFUL_SPACE_ID: ${CONTENTFUL_SPACE_ID}
        CONTENTFUL_DELIVERY_TOKEN: ${CONTENTFUL_DELIVERY_TOKEN}
    ports:
      - "80:80"
    restart: unless-stopped
docker compose up --build -d

Same workflow. Only change is the Contentful build args.

CI/CD with GitHub Actions

Here’s where it gets nice. Deploy on every push to main, and also when content gets published in Contentful:

name: Build and Deploy

on:
  push:
    branches: [main]
  repository_dispatch:
    types: [contentful_publish]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/Dockerfile
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          build-args: |
            CONTENTFUL_SPACE_ID=${{ secrets.CONTENTFUL_SPACE_ID }}
            CONTENTFUL_DELIVERY_TOKEN=${{ secrets.CONTENTFUL_DELIVERY_TOKEN }}            
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker compose -f /opt/website/docker-compose.yml up -d            

Contentful webhook. Create a webhook that triggers the GitHub dispatch:

URL: https://api.github.com/repos/YOUR_USER/YOUR_REPO/dispatches
Method: POST
Headers:
  Accept: application/vnd.github.v3+json
  Authorization: Bearer YOUR_GITHUB_PAT
Body:
  { "event_type": "contentful_publish" }

Publish something in Contentful → webhook fires → GitHub Action builds → new Docker image → deployed. About 2 minutes start to finish.

SSL if needed

If you’re not behind a load balancer, a host-level Nginx can handle SSL:

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

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

    ssl_certificate /etc/letsencrypt/live/oltionzefi.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/oltionzefi.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000" always;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Host Nginx handles SSL, proxies to the Docker container.

What we’d end up with

├── astro.config.mjs
├── package.json
├── tsconfig.json
├── docker/
│   ├── Dockerfile
│   └── nginx.conf
├── docker-compose.yml
├── .github/workflows/deploy.yml
├── public/
│   ├── favicon.svg
│   └── robots.txt
├── src/
│   ├── components/
│   ├── layouts/
│   ├── lib/
│   ├── pages/
│   ├── styles/
│   └── types/
└── scripts/
    └── migrate-to-contentful.ts

What’s next

The deployment story is solid. Two more posts to go — Part 6 covers adding Zod schemas to validate Contentful data at build time, and Part 7 tackles securing secrets in Docker builds so your tokens never leak into image layers.

Keep pushing forward and savor every step of your coding journey.