How I Built This Blog: Astro, Docker Images, Caddy and Zero-Touch CI/CD
The complete story of building my personal blog with Astro's versatility, Caddy instead of Nginx, Docker containerization, and GitHub Actions for automated deployments.
How I Built This Blog: Astro, Docker Images, Caddy and Zero-Touch CI/CD
In this article, I want to share the complete experience of designing and building this blog β from the initial technology choices to fully automated deployments on a VPS. Itβs been a journey that allowed me to combine my passions for web development and DevOps into a single, elegant solution.
π― The Goal
It all started with a simple need: I wanted a place to share my technical experiences, projects, and thoughts on software development. But I didnβt want just any blog.
My Requirements
My needs were clear from the start:
- High performance: The site had to be fast, with minimal loading times
- SEO optimized: Important for technical content visibility
- Easy writing: Use Markdown/MDX to write articles without complications
- Automated deployment: Push to GitHub = site updated, no manual intervention
- Fully containerized: Docker images for reproducibility and easy deployment
- Automatic HTTPS: SSL certificates managed automatically
The main challenge? Combining all these requirements into a simple yet robust architecture where everything runs as Docker images.
π Why Astro? The Ultimate Versatile Framework
After evaluating several options (Next.js, Hugo, Gatsby, Jekyll), I chose Astro β and it turned out to be the perfect decision.
Astroβs Incredible Versatility
What makes Astro stand out is its βbring your own frameworkβ philosophy combined with zero JavaScript by default:
// Astro's philosophy: Zero JavaScript by default
// The result? Ultra-fast static HTML pages
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap(), tailwind()],
});
What Makes Astro Special
| Feature | Benefit |
|---|---|
| Islands Architecture | Interactive components only where needed |
| Framework Agnostic | Use React, Vue, Svelte, or plain HTML |
| Zero JS Runtime | Pure static pages, no framework in the browser |
| Content Collections | Type-safe content management with Zod |
| Built-in Optimizations | Automatic image optimization, prefetching |
| MDX Support | Markdown with components inline |
| Vite-powered | Lightning-fast builds and HMR |
Content Collections: Type-Safe Blogging
One of Astroβs most powerful features is its typed content management:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
excerpt: z.string().optional(),
date: z.coerce.date(),
author: z.string().optional(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
}),
});
export const collections = { blog };
This schema ensures every article has the correct fields and types, catching errors at build time β not in production!
Why Astro Over Alternatives?
| Framework | Pros | Why I Chose Astro Instead |
|---|---|---|
| Next.js | Full-stack, great DX | Overkill for a static blog, ships too much JS |
| Gatsby | Rich plugin ecosystem | Complex configuration, slow builds |
| Hugo | Blazing fast | Go templating is not as flexible |
| Jekyll | Simple, proven | Ruby dependency, limited modern features |
| Astro | β Best of all worlds | Zero JS, fast builds, MDX, type-safe |
π Caddy vs Nginx: Choosing the Right Web Server
For this project, I chose Caddy over Nginx β but this decision comes with trade-offs worth understanding.
Why I Chose Caddy for This Project
For a personal blog or showcase site, Caddy offers significant advantages in simplicity:
{
email admin@example.com
admin off
}
# Redirect www to non-www
www.example.com {
redir https://example.com{uri} permanent
}
# Main site configuration
example.com {
reverse_proxy web:80
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
-Server
}
}
Caddy vs Nginx: Honest Comparison
| Aspect | Caddy | Nginx |
|---|---|---|
| SSL Setup | Automatic (Letβs Encrypt) | Manual with Certbot |
| Configuration | Simple, ~10 lines | More verbose, ~50+ lines |
| Learning Curve | Minimal | Steeper |
| HTTP/3 | Built-in | Requires compilation |
| Performance | Great for most cases | Highly optimized, battle-tested |
| Enterprise Features | Limited | Extensive (load balancing, caching) |
| Community/Docs | Growing | Massive, mature |
| Complex Routing | Basic | Very powerful |
When to Use Caddy β
Caddy shines in these scenarios:
- Homelabs: Quick setup, automatic SSL, minimal maintenance
- Showcase/Portfolio Sites: Simple configuration, just works
- Small Projects: Less overhead, faster to deploy
- Development Environments: Easy local HTTPS setup
When Nginx Might Be Better β οΈ
Consider Nginx for:
- Enterprise Environments: More control, proven at scale
- Complex Load Balancing: Advanced upstream configurations
- High-Traffic Sites: Fine-tuned performance options
- Legacy Integration: Better compatibility with existing infrastructure
- Custom Modules: Extensive ecosystem of modules
My take: For a personal blog like this one, Caddyβs simplicity wins. But for enterprise or high-complexity scenarios, Nginxβs maturity and flexibility are hard to beat.
π³ Everything as Docker Images: The Container-First Approach
One of my core principles was: everything must run as Docker images. This approach has transformed how I deploy and maintain the blog.
Why Container Everything?
# The dream: Deploy anywhere with one command
docker-compose up -d
# That's it. Works on any server with Docker.
Multi-Stage Build: Optimized Images
I use a multi-stage Dockerfile to create minimal, secure images:
# Stage 1: Build the Astro site
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies (cached layer)
COPY astro-site/package*.json ./
RUN npm install
# Build the static site
COPY astro-site/ ./
RUN npm run build
# Stage 2: Serve with Caddy (tiny final image!)
FROM caddy:2-alpine
WORKDIR /srv
# Copy ONLY the built static files
COPY --from=builder /app/dist /srv
EXPOSE 80
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]
Benefits of the Container-First Approach
| Benefit | Description |
|---|---|
| Reproducibility | Same image runs identically everywhere |
| Isolation | No βworks on my machineβ problems |
| Security | Minimal attack surface in production |
| Rollback | Just pull the previous image tag |
| Scaling | Easy horizontal scaling if needed |
| CI/CD Integration | Images are the deployment artifact |
Image Size Optimization
Thanks to multi-stage builds:
Node.js builder stage: ~1.2GB (discarded)
Final Caddy image: ~50MB (shipped)
The production image contains:
- β Caddy binary (~40MB)
- β Static HTML/CSS/JS files (~10MB)
- β No Node.js
- β No npm
- β No build tools
- β No source code
Docker Compose Orchestration
services:
web:
image: ghcr.io/your-username/your-blog:latest
expose:
- "80"
restart: unless-stopped
networks:
- app-network
caddy:
image: ghcr.io/your-username/your-blog-caddy:latest
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 support
volumes:
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
restart: unless-stopped
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
caddy_data:
caddy_config:
π CI/CD: The Zero-Touch Deployment Pipeline
The heart of automation is the CI/CD pipeline I built with GitHub Actions. The goal: push to main, site updates automatically.
The Complete Pipeline Architecture
βββββββββββββββ ββββββββββββββββ βββββββββββββββ βββββββββββββ
β Git Push ββββββΆβ Build Image ββββββΆβ Push GHCR ββββββΆβ Deploy β
β to main β β (Astro+Caddy)β β (Registry) β β to VPS β
βββββββββββββββ ββββββββββββββββ βββββββββββββββ βββββββββββββ
β β β
βΌ βΌ βΌ
Multi-stage build GitHub Container SSH + docker-compose
Node.js β Caddy Registry pull & up
Pipeline 1: Build and Push Docker Images
name: Build and Push Docker Image to GHCR
on:
push:
branches:
- main
workflow_dispatch: # Manual trigger option
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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 for web image
id: meta-web
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push web Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
- name: Build and push Caddy Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.caddy
push: true
tags: ghcr.io/${{ env.IMAGE_NAME }}-caddy:latest
Pipeline 2: Deploy to VPS
name: Deploy to VPS
on:
workflow_run:
workflows: ["Build and Push Docker Image to GHCR"]
types:
- completed
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 2222
script: |
cd ~/your-blog
git pull origin main
docker-compose pull
docker-compose down
docker-compose up -d
docker system prune -f
The Zero-Touch Flow
- I write an article in MDX format
- Git push to main triggers the pipeline
- GitHub Actions builds the Docker images
- Images are pushed to GitHub Container Registry
- Deploy workflow SSHs into VPS
- docker-compose pull gets the new images
- Site is live with zero manual intervention
Total time from push to live: ~2 minutes
Why This CI/CD Setup is Powerful
| Feature | Benefit |
|---|---|
| Workflow Chaining | Deploy only runs after successful build |
| Image Tagging | SHA-based tags for traceability |
| Automatic Cleanup | docker system prune keeps VPS clean |
| Rollback Ready | Previous images always available |
| Manual Trigger | workflow_dispatch for emergency deploys |
π Project Architecture
The architecture is designed to be simple yet scalable.
Directory Structure
your-blog/
βββ astro-site/ # Astro application
β βββ src/
β β βββ pages/ # Site pages
β β βββ layouts/ # Reusable layouts
β β βββ components/ # UI components
β β βββ content/ # Blog articles (MDX)
β β βββ styles/ # Global styles
β βββ public/ # Static assets
βββ .github/
β βββ workflows/ # GitHub Actions pipelines
βββ Dockerfile # Multi-stage build
βββ Dockerfile.caddy # Caddy reverse proxy
βββ docker-compose.yml # Container orchestration
βββ Caddyfile # Caddy configuration
βββ astro-dev.sh # Development helper script
π Writing Articles with MDX
One of the most satisfying parts is how simple it is to write new articles.
Article Structure
Each article starts with a YAML frontmatter block containing metadata:
---
title: "Article Title"
date: "2024-11-24"
excerpt: "Brief description for preview"
author: "Your Name"
tags: ["Tag1", "Tag2", "Tag3"]
---
Then you write your content using standard Markdown syntax with support for:
- Headers: Use
#,##,###for different heading levels - Emphasis: Use
**bold**and*italic*formatting - Code: Use backticks for
inline codeor triple backticks for code blocks - Quotes: Use
>for blockquotes - Lists: Use
-or1.for bullet or numbered lists
The MDX format combines the simplicity of Markdown with the power of components!
π‘ Lessons Learned
This project taught me a lot. Here are the most important lessons:
1. Choose the Right Tool for the Job
Caddy is excellent for homelabs, personal projects, and showcase sites where simplicity matters. For enterprise scenarios with complex requirements, Nginxβs maturity and extensive features might be the better choice. Always evaluate your specific needs.
2. Docker Images = Portability
By containerizing everything, I achieved true βbuild once, run anywhereβ:
# Deploy to any server:
docker-compose up -d
# Done. Works everywhere.
3. CI/CD Pays Off Immediately
The time invested in setting up the pipeline pays off with every single deployment:
- No manual errors
- Deploy anytime with confidence
- Simple rollback (just deploy previous image)
4. Astroβs Versatility is Unmatched
Astro lets me:
- Write in Markdown/MDX
- Add interactivity only where needed
- Ship zero JavaScript by default
- Use any UI framework if I want
π Results
After all this work, the results are tangible:
| Metric | Value |
|---|---|
| Lighthouse Performance | 100/100 |
| Time to First Byte | < 100ms |
| First Contentful Paint | < 1s |
| Build Time | ~30 seconds |
| Deploy Time | < 2 minutes |
| Docker Image Size | ~50MB |
π¬ Conclusion
Building this blog has been an incredibly rewarding experience. I combined technologies that complement each other well β Astro for its versatility, Caddy for simplified web serving (ideal for this use case), Docker for containerization, and GitHub Actions for zero-touch CI/CD β into a system that works smoothly and automatically.
Key Takeaways
- Astro is perfect for blogs and content-driven sites β versatile, fast, and developer-friendly
- Choose wisely between Caddy and Nginx based on your specific needs β simplicity vs. enterprise features
- Docker images for everything ensures consistent deployments anywhere
- CI/CD automation makes deployment a non-event β push and forget
If youβre thinking about creating your own technical blog, I hope this article gave you some useful insights!
π Resources
- Astro Documentation
- Caddy Server β The HTTP/2 web server with automatic HTTPS
- Nginx Documentation β For enterprise and complex configurations
- Docker Documentation
- GitHub Actions
- Tailwind CSS
Thanks for reading! If you have questions or want to share your experience building blogs, feel free to reach out.
Related Articles
Building a Django Hotel Booking System - Part 2: Cloud Deployment on Azure
November 22, 2024
Deploy a Django application to Azure using Infrastructure as Code with Terraform, CI/CD pipelines with GitHub Actions, and modern DevOps practices for zero-touch deployment.