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 ciinstead ofapk 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.
