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 /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
- Multi-stage build: Separates build dependencies from runtime, reducing image size by ~90%
- Corepack for Yarn 4: Modern package manager support
- Health endpoint: Kubernetes-ready liveness/readiness probes
- Nginx optimizations: Proper caching headers and SPA routing
- 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
- Non-root containers: Running as user 101 (nginx user)
- Drop all capabilities: Minimal Linux capabilities
- Read-only root filesystem where possible
- Resource limits: Prevent resource exhaustion
- 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
- Next.js Static Export Documentation
- Kubernetes Documentation
- Cloudflare Tunnel Documentation
- Docker Multi-stage Builds
Tools and Services
- Talos Linux - Immutable Kubernetes OS
- GitHub Actions
- GitHub Container Registry
- Nginx
Code Repository
- GitHub: georg-nikola/blog-NextJS
- Kubernetes Manifests: talos-configs/local-cluster-config
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!