How I Built This Blog: Astro, Docker Images, Caddy and Zero-Touch CI/CD

by Cristian Caiazzo
Astro Docker GitHub Actions DevOps Caddy 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:

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

FeatureBenefit
Islands ArchitectureInteractive components only where needed
Framework AgnosticUse React, Vue, Svelte, or plain HTML
Zero JS RuntimePure static pages, no framework in the browser
Content CollectionsType-safe content management with Zod
Built-in OptimizationsAutomatic image optimization, prefetching
MDX SupportMarkdown with components inline
Vite-poweredLightning-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?

FrameworkProsWhy I Chose Astro Instead
Next.jsFull-stack, great DXOverkill for a static blog, ships too much JS
GatsbyRich plugin ecosystemComplex configuration, slow builds
HugoBlazing fastGo templating is not as flexible
JekyllSimple, provenRuby dependency, limited modern features
Astroβœ… Best of all worldsZero 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

AspectCaddyNginx
SSL SetupAutomatic (Let’s Encrypt)Manual with Certbot
ConfigurationSimple, ~10 linesMore verbose, ~50+ lines
Learning CurveMinimalSteeper
HTTP/3Built-inRequires compilation
PerformanceGreat for most casesHighly optimized, battle-tested
Enterprise FeaturesLimitedExtensive (load balancing, caching)
Community/DocsGrowingMassive, mature
Complex RoutingBasicVery powerful

When to Use Caddy βœ…

Caddy shines in these scenarios:

When Nginx Might Be Better ⚠️

Consider Nginx for:

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

BenefitDescription
ReproducibilitySame image runs identically everywhere
IsolationNo β€œworks on my machine” problems
SecurityMinimal attack surface in production
RollbackJust pull the previous image tag
ScalingEasy horizontal scaling if needed
CI/CD IntegrationImages 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:

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

  1. I write an article in MDX format
  2. Git push to main triggers the pipeline
  3. GitHub Actions builds the Docker images
  4. Images are pushed to GitHub Container Registry
  5. Deploy workflow SSHs into VPS
  6. docker-compose pull gets the new images
  7. Site is live with zero manual intervention

Total time from push to live: ~2 minutes

Why This CI/CD Setup is Powerful

FeatureBenefit
Workflow ChainingDeploy only runs after successful build
Image TaggingSHA-based tags for traceability
Automatic Cleanupdocker system prune keeps VPS clean
Rollback ReadyPrevious images always available
Manual Triggerworkflow_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:

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:

4. Astro’s Versatility is Unmatched

Astro lets me:


πŸ“Š Results

After all this work, the results are tangible:

MetricValue
Lighthouse Performance100/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

If you’re thinking about creating your own technical blog, I hope this article gave you some useful insights!


πŸ“š Resources


Thanks for reading! If you have questions or want to share your experience building blogs, feel free to reach out.

Related Articles

Back to Blog