Skillversitas

Caddy on a MicroVM

Caddy is a modern web server with automatic HTTPS, zero-config, and a simpler configuration than Nginx. It is very well suited to MicroVMs thanks to its static binary and ease of deployment.

Why Caddy?

Feature Nginx Caddy
Automatic HTTPS No (requires certbot) Yes (built-in)
Configuration Complex Simple
Binary size ~5MB ~15MB
Performance Very good Very good
Modules Many Limited but growing
Go binary No Yes (static)

Preparing the Image

sudo mount -o loop rootfs.img /mnt/microvm

# Download Caddy binary
wget -q https://caddyserver.com/api/download?os=linux&arch=amd64 -O /tmp/caddy
chmod +x /tmp/caddy
cp /tmp/caddy /mnt/microvm/usr/local/bin/caddy

# Or install via the package manager
# Debian/Ubuntu
sudo chroot /mnt/microvm bash -c '
  apt update && apt install -y debian-keyring debian-archive-keyring apt-transport-https
  curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
  curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | tee /etc/apt/sources.list.d/caddy-stable.list
  apt update
  apt install caddy
'

# Alpine
sudo chroot /mnt/microvm bash -c '
  apk add caddy
'

sudo umount /mnt/microvm

Configuring the Caddyfile

cat > /etc/caddy/Caddyfile << 'CADDY'
# Global options
{
    email [email protected]
    # default_sni example.com
}

# Static site
example.com {
    root * /var/www/html
    file_server
    encode gzip zstd

    header {
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
    }

    log {
        output file /var/log/caddy/access.log
        format json
    }
}

# Reverse proxy
api.example.com {
    reverse_proxy 127.0.0.1:3000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

# API with rate limiting
api.example.com/api/* {
    reverse_proxy 127.0.0.1:3000

    rate_limit {
        zone api {
            key {remote_host}
            events 100
            window 1m
        }
    }
}
CADDY

Running the MicroVM

qemu-system-x86_64 \
  -M microvm -enable-kvm -cpu host -m 256m -smp 2 \
  -kernel vmlinuz \
  -append "console=ttyS0 root=/dev/vda rw" \
  -drive id=rootfs,file=rootfs.img,format=raw,if=none,cache=none \
  -device virtio-blk-device,drive=rootfs \
  -netdev user,id=net0,hostfwd=tcp::80-:80,hostfwd=tcp::443-:443 \
  -device virtio-net-device,dev=net0 \
  -nographic -serial stdio

Setting Up the Guest

# Start Caddy
caddy start --config /etc/caddy/Caddyfile

# or with systemd
systemctl start caddy

# Test
curl -I http://localhost

Auto Start

sudo tee /mnt/microvm/etc/init.d/caddy << 'CADDYINIT'
#!/sbin/openrc-run

CADDY=/usr/local/bin/caddy
CONFIG=/etc/caddy/Caddyfile
PIDFILE=/run/caddy.pid

depend() {
    need net
    keyword timeout 60
}

start() {
    ebegin "Starting Caddy"
    mkdir -p /var/log/caddy /run
    start-stop-daemon --start --background \
        --make-pidfile --pidfile $PIDFILE \
        --exec $CADDY -- start --config $CONFIG
    eend $?
}

stop() {
    ebegin "Stopping Caddy"
    start-stop-daemon --stop --pidfile $PIDFILE
    eend $?
}
CADDYINIT

sudo chmod +x /mnt/microvm/etc/init.d/caddy
sudo chroot /mnt/microvm rc-update add caddy default

Automatic HTTPS

Caddy automatically obtains and renews SSL certificates:

# Caddyfile - automatic HTTPS
example.com {
    root * /var/www/html
    file_server
}

# Wildcard domain (requires a DNS challenge)
*.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    reverse_proxy /* 127.0.0.1:3000
}

Self-Signed Certificate (No Domain)

# For testing without a domain
localhost {
    tls internal
    reverse_proxy 127.0.0.1:3000
}

Reverse Proxy

# Single backend
example.com {
    reverse_proxy 127.0.0.1:3000
}

# Multiple backends (load balancing)
example.com {
    reverse_proxy 127.0.0.1:3000 127.0.0.1:3001 127.0.0.1:3002 {
        lb_policy round_robin
        health_uri /health
        health_interval 10s
    }
}

# With headers
example.com {
    reverse_proxy 127.0.0.1:3000 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}

        header_down -Server
    }
}

File Server

# Serve static files
files.example.com {
    root * /var/www/files
    file_server {
        browse
    }

    # Hide sensitive files
    @hidden path */.*
    handle @hidden {
        respond "Forbidden" 403
    }
}

Log Configuration

# JSON format (for ELK/Loki)
log {
    output file /var/log/caddy/access.log
    format json
    level INFO
}

# Custom format
log {
    output file /var/log/caddy/access.log
    format console
    level INFO
}

# Per-site log
example.com {
    log {
        output file /var/log/caddy/example.log
    }
}

Caddy API

Caddy can be configured via the API without a restart:

# Fetch the current configuration
curl http://localhost:2019/config/

# Update config via API
curl -X POST http://localhost:2019/config/ \
  -H "Content-Type: application/json" \
  -d '{
    "apps": {
      "http": {
        "servers": {
          "srv0": {
            "listen": [":80"],
            "routes": [{
              "match": [{"host": ["example.com"]}],
              "handle": [{"handler": "file_server", "root": "/var/www/html"}]
            }]
          }
        }
      }
    }
  }'

Monitoring

# Check the status
caddy version

# Check the configuration validity
caddy validate --config /etc/caddy/Caddyfile

# Reload config
caddy reload --config /etc/caddy/Caddyfile

# Logs
tail -f /var/log/caddy/access.log

Troubleshooting

Ports 80/443 are already in use

# Check what is using the ports
ss -tlnp | grep :80
ss -tlnp | grep :443

# Stop the other service or change Caddy's port
:8080 {
    reverse_proxy 127.0.0.1:3000
}

Certificate fails

# Check DNS resolution
dig example.com

# For self-signed testing
localhost {
    tls internal
}

# Check the Caddy logs
journalctl -u caddy -f

Permission error

# Caddy needs permission to bind ports below 1024
# Solution: run as root or use capabilities
setcap 'cap_net_bind_service=+ep' /usr/local/bin/caddy