Lazy Dev

2025-11-05

Modernizing a Next.js Blog: From Static Hosting to Kubernetes with Cloudflare Tunnel

Modernizing a Next.js Blog: From Static Hosting to Kubernetes with Cloudflare Tunnel

Introduction

In this comprehensive guide, I'll walk you through the complete modernization journey of transforming a traditional Next.js blog into a cloud-native application deployed on Kubernetes, secured with Cloudflare Tunnel, and automated with CI/CD pipelines. This represents a real-world case study of applying enterprise-grade infrastructure patterns to personal projects.

Keywords: Next.js 16, Kubernetes deployment, Cloudflare Tunnel, Docker containerization, GitHub Actions CI/CD, Talos Linux, static site generation, cloud-native architecture, nginx container, infrastructure as code

The Challenge: Moving Beyond Traditional Hosting

Traditional static site hosting has limitations when you want to:

  • Implement advanced routing and caching strategies
  • Integrate with existing Kubernetes infrastructure
  • Maintain consistent deployment patterns across projects
  • Leverage zero-trust network architecture
  • Automate deployments with proper testing gates

This blog modernization addresses all these requirements while maintaining the simplicity and performance benefits of static site generation.

Architecture Overview

High-Level Architecture

┌─────────────────┐     ┌──────────────────┐     ┌────────────────────┐
│  GitHub Actions │────▶│  GHCR Registry   │────▶│  Kubernetes Cluster│
│   CI/CD Pipeline│     │  (Docker Images) │     │   (Talos Linux)    │
└─────────────────┘     └──────────────────┘     └────────────────────┘
                                                            │
                                                            ▼
                                                   ┌─────────────────┐
                                                   │ Cloudflare Tunnel│
                                                   │   Zero-Trust     │
                                                   └─────────────────┘
                                                            │
                                                            ▼
                                                   ┌─────────────────┐
                                                   │  Public Internet │
                                                   │ (blog.domain.com)│
                                                   └─────────────────┘

Technology Stack

  • Framework: Next.js 16 with Static Export
  • Containerization: Multi-stage Docker builds
  • Container Registry: GitHub Container Registry (GHCR)
  • Orchestration: Kubernetes 1.25+ on Talos Linux
  • Networking: Cloudflare Tunnel with DNS management
  • CI/CD: GitHub Actions with automated testing
  • Infrastructure: Talos Linux for immutable Kubernetes nodes

Step 1: Containerizing the Next.js Application

Multi-Stage Dockerfile

The key to efficient containerization is using multi-stage builds to minimize image size and attack surface:

# Build stage - Node.js 25 Alpine for maximum performance
FROM node:25-alpine AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Copy package files and enable Corepack for Yarn 4
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn

RUN npm install -g --force corepack && \
    corepack enable && \
    yarn install --immutable

# Build static site
COPY . .
RUN yarn build

# Production stage - Minimal nginx image
FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html

# Remove default nginx content
RUN rm -rf ./*

# Copy static build from builder
COPY --from=builder /app/out .

# Add health check endpoint
RUN echo "OK" > health

# Nginx configuration for SPA routing
COPY <<EOF /etc/nginx/conf.d/default.conf
server {
    listen 80;
    server_name _;

    # Health check endpoint
    location /health {
        access_log off;
        add_header Content-Type text/plain;
        return 200 "OK";
    }

    # Static content with caching
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files \$uri \$uri/ /index.html;

        # Cache static assets
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }
}
EOF

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

Key Dockerfile Features

  1. Multi-stage build: Separates build dependencies from runtime, reducing image size by ~90%
  2. Corepack for Yarn 4: Modern package manager support
  3. Health endpoint: Kubernetes-ready liveness/readiness probes
  4. Nginx optimizations: Proper caching headers and SPA routing
  5. Security hardening: Non-root user, minimal base image

Step 2: GitHub Actions CI/CD Pipeline

Automated Docker Build and Publish

name: Docker Build and Publish

on:
  push:
    tags:
      - 'v*.*.*'  # Trigger on semantic version tags

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      attestations: write  # Enable provenance attestations

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          platforms: linux/amd64,linux/arm64
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Generate attestation
        uses: actions/attest-build-provenance@v2
        with:
          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

Pull Request Testing Workflow

name: PR Tests

on:
  pull_request:
    branches:
      - main

jobs:
  lint-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Setup Node.js 20
        uses: actions/setup-node@v6
        with:
          node-version: '20'

      - name: Enable Corepack
        run: corepack enable

      - name: Install dependencies
        run: yarn install --immutable

      - name: Run linter
        run: yarn lint

      - name: Build static site
        run: yarn build

      - name: Verify build output
        run: |
          if [ ! -d "out" ]; then
            echo "Build output missing"
            exit 1
          fi

  docker-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build Docker image (test only)
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: blog-nextjs:test
          cache-from: type=gha
          cache-to: type=gha,mode=max
          load: true

      - name: Test Docker image
        run: |
          docker run -d --name blog-test -p 8080:80 blog-nextjs:test
          sleep 3
          curl -f http://localhost:8080/health || exit 1
          curl -f http://localhost:8080/ || exit 1
          docker stop blog-test

Step 3: Kubernetes Deployment

Deployment Manifest

apiVersion: apps/v1
kind: Deployment
metadata:
  name: blog
  namespace: default
  labels:
    app: blog
spec:
  replicas: 2
  selector:
    matchLabels:
      app: blog
  template:
    metadata:
      labels:
        app: blog
    spec:
      imagePullSecrets:
      - name: ghcr-secret
      containers:
      - name: blog
        image: ghcr.io/georg-nikola/blog-nextjs:1.4.0
        imagePullPolicy: Always
        ports:
        - containerPort: 80
          name: http
        resources:
          requests:
            memory: "64Mi"
            cpu: "100m"
          limits:
            memory: "128Mi"
            cpu: "200m"
        livenessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 10
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 5
        securityContext:
          runAsNonRoot: true
          runAsUser: 101
          allowPrivilegeEscalation: false
          capabilities:
            drop:
            - ALL
          readOnlyRootFilesystem: false
      tolerations:
      - key: node-role.kubernetes.io/control-plane
        operator: Exists
        effect: NoSchedule
      securityContext:
        fsGroup: 101
---
apiVersion: v1
kind: Service
metadata:
  name: blog
  namespace: default
spec:
  type: ClusterIP
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
    name: http
  selector:
    app: blog

Security Best Practices

  1. Non-root containers: Running as user 101 (nginx user)
  2. Drop all capabilities: Minimal Linux capabilities
  3. Read-only root filesystem where possible
  4. Resource limits: Prevent resource exhaustion
  5. Health checks: Automatic pod restart on failure

Step 4: Cloudflare Tunnel Integration

Why Cloudflare Tunnel?

Cloudflare Tunnel provides several advantages over traditional ingress:

  • Zero-trust architecture: No open firewall ports
  • Built-in DDoS protection: Cloudflare's global network
  • Automatic SSL/TLS: Managed certificates
  • Access control: Identity-based authentication
  • Traffic analytics: Built-in observability

Cloudflare Tunnel Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflare-tunnel
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        fsGroup: 65532
      containers:
      - name: cloudflared
        image: cloudflare/cloudflared:2024.10.1
        args:
        - tunnel
        - --config
        - /etc/cloudflared/config.yaml
        - run
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop:
            - ALL
          readOnlyRootFilesystem: true
        resources:
          requests:
            cpu: 50m
            memory: 64Mi
          limits:
            cpu: 200m
            memory: 128Mi
        volumeMounts:
        - name: config
          mountPath: /etc/cloudflared
          readOnly: true
        - name: credentials
          mountPath: /etc/cloudflared-creds
          readOnly: true
      volumes:
      - name: config
        configMap:
          name: cloudflared-config
      - name: credentials
        secret:
          secretName: cloudflare-tunnel-credentials

Cloudflare Tunnel Configuration

apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared-config
  namespace: cloudflare-tunnel
data:
  config.yaml: |
    tunnel: YOUR_TUNNEL_ID
    credentials-file: /etc/cloudflared-creds/credentials.json

    ingress:
      - hostname: blog.your-domain.com
        service: http://blog.default.svc.cluster.local:80
      - service: http_status:404

Setting Up Cloudflare Tunnel

# Install cloudflared CLI
brew install cloudflared  # macOS
# or download from: https://github.com/cloudflare/cloudflared/releases

# Authenticate with Cloudflare
cloudflared tunnel login

# Create tunnel
cloudflared tunnel create blog-tunnel

# Create Kubernetes secret with credentials
kubectl create secret generic cloudflare-tunnel-credentials \
  --from-file=credentials.json=~/.cloudflared/YOUR_TUNNEL_ID.json \
  -n cloudflare-tunnel

# Configure DNS in Cloudflare dashboard
# Add CNAME record: blog.your-domain.com -> YOUR_TUNNEL_ID.cfargotunnel.com

Step 5: Deployment Workflow

Manual Deployment Process

# 1. Create and push git tag
git tag -a v1.4.0 -m "Release v1.4.0 - Next.js 16 upgrade"
git push origin v1.4.0

# 2. Wait for Docker build (GitHub Actions)
# Check: https://github.com/your-repo/actions

# 3. Update Kubernetes deployment
kubectl set image deployment/blog \
  blog=ghcr.io/georg-nikola/blog-nextjs:1.4.0 \
  -n default

# 4. Monitor rollout
kubectl rollout status deployment/blog -n default

# 5. Verify deployment
kubectl get pods -l app=blog -n default
curl -f https://blog.your-domain.com/health

Automated Deployment with ArgoCD (Optional)

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: blog
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/georg-nikola/blog-NextJS.git
    targetRevision: main
    path: deployments/kubernetes
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true

Performance Optimizations

1. Nginx Caching Strategy

The nginx configuration implements aggressive caching for static assets:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

2. Docker Image Optimizations

  • Multi-stage builds: Reduced image size from ~1.2GB to ~45MB
  • Alpine base: Minimal attack surface
  • Layer caching: Faster builds with GitHub Actions cache

3. Kubernetes Resource Management

resources:
  requests:
    memory: "64Mi"    # Guaranteed resources
    cpu: "100m"
  limits:
    memory: "128Mi"   # Maximum allowed
    cpu: "200m"

4. Horizontal Pod Autoscaling (HPA)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: blog
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: blog
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

Monitoring and Observability

Prometheus Metrics

Add Prometheus annotations to the deployment:

template:
  metadata:
    annotations:
      prometheus.io/scrape: "true"
      prometheus.io/port: "9113"  # nginx-prometheus-exporter
      prometheus.io/path: "/metrics"

Logging with Fluent Bit

Kubernetes automatically collects container logs. Use Fluent Bit for log aggregation:

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
data:
  fluent-bit.conf: |
    [INPUT]
        Name tail
        Path /var/log/containers/blog-*.log
        Parser docker
        Tag kube.*

    [OUTPUT]
        Name es
        Match kube.*
        Host elasticsearch.logging.svc.cluster.local
        Port 9200

Security Considerations

1. Image Security

  • Scan images: GitHub Advanced Security with Dependabot
  • Sign images: Use Cosign for image signing
  • SBOM generation: Software Bill of Materials with Syft
# Sign container image
cosign sign ghcr.io/georg-nikola/blog-nextjs:1.4.0

# Generate SBOM
syft ghcr.io/georg-nikola/blog-nextjs:1.4.0 -o spdx-json > sbom.json

2. Network Policies

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: blog-network-policy
spec:
  podSelector:
    matchLabels:
      app: blog
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          name: cloudflare-tunnel
    ports:
    - protocol: TCP
      port: 80
  egress:
  - to:
    - namespaceSelector: {}
    ports:
    - protocol: TCP
      port: 53  # DNS

3. Pod Security Standards

apiVersion: v1
kind: Namespace
metadata:
  name: default
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

Cost Analysis

Infrastructure Costs (Monthly)

  • Kubernetes Cluster: $0 (home server with Talos Linux)
  • Cloudflare Tunnel: $0 (free tier sufficient)
  • GitHub Actions: $0 (free for public repos)
  • GitHub Container Registry: $0 (500MB free storage)
  • Cloudflare DNS: $0 (included)

Total Monthly Cost: $0

Compare to Traditional Hosting

  • Netlify Pro: $19/month
  • Vercel Pro: $20/month
  • AWS Amplify: ~$15/month

Annual Savings: ~$240

Troubleshooting Common Issues

Issue 1: ImagePullBackOff

# Check image pull secret
kubectl get secret ghcr-secret -n default -o yaml

# Create new secret if needed
kubectl create secret docker-registry ghcr-secret \
  --docker-server=ghcr.io \
  --docker-username=YOUR_GITHUB_USERNAME \
  --docker-password=YOUR_GITHUB_TOKEN \
  -n default

Issue 2: CrashLoopBackOff

# Check pod logs
kubectl logs -l app=blog -n default --tail=50

# Describe pod for events
kubectl describe pod -l app=blog -n default

# Common causes:
# - Missing health endpoint
# - Incorrect nginx configuration
# - Resource limits too low

Issue 3: Cloudflare Tunnel Not Connecting

# Check cloudflared logs
kubectl logs -l app=cloudflared -n cloudflare-tunnel

# Verify credentials
kubectl get secret cloudflare-tunnel-credentials -n cloudflare-tunnel -o yaml

# Test tunnel connectivity
cloudflared tunnel --config config.yaml run

Lessons Learned

1. Start Simple, Scale Gradually

Begin with basic Docker deployment, then add:

  • CI/CD automation
  • Health checks
  • Monitoring
  • Auto-scaling

2. Invest in Automation Early

Automated testing and deployment save significant time and reduce errors.

3. Security as a Foundation

Implementing security best practices from the start is easier than retrofitting later.

4. Documentation Matters

Maintain deployment documentation alongside code. Tools like CHANGELOG.md and deployment READMEs are invaluable.

5. Observability is Critical

Without proper monitoring and logging, troubleshooting production issues becomes nearly impossible.

Next Steps and Improvements

Short-term

  • Implement automated rollback on deployment failure
  • Add Grafana dashboards for metrics visualization
  • Set up AlertManager for proactive incident response
  • Implement blue-green deployments

Long-term

  • Multi-cluster deployment for high availability
  • Edge caching with Cloudflare Workers
  • A/B testing infrastructure
  • Advanced SEO optimizations with structured data

Conclusion

This modernization journey demonstrates how to apply enterprise-grade infrastructure patterns to personal projects without incurring costs. The combination of Kubernetes, Cloudflare Tunnel, and GitHub Actions creates a robust, secure, and scalable platform for hosting static sites.

Key takeaways:

  • Containerization enables consistent deployments across environments
  • Kubernetes provides enterprise-grade orchestration at no cost on home servers
  • Cloudflare Tunnel eliminates the need for traditional ingress controllers
  • CI/CD automation ensures quality and reduces manual deployment errors
  • Infrastructure as Code makes the entire setup reproducible and maintainable

The total setup time was approximately 8 hours, but the long-term benefits in maintainability, security, and scalability make it worthwhile for any serious technical blog or portfolio site.

Resources and References

Documentation

Tools and Services

Code Repository


This blog post documents a real production deployment. All code and configurations are available in the linked repositories. Feel free to adapt this approach for your own projects!

Previous

Migrating from AWS RDS to Aurora: A Step-by-Step Guide

Next

Building Sentinel Mesh: A Cloud-Native Monitoring Platform with ML-Powered Intelligence