Skip to content

Caddy Web Server

Overview

Caddy is a powerful, enterprise-ready web server with automatic HTTPS. In Quill Medical, Caddy serves as our reverse proxy, handling incoming HTTP/HTTPS requests and routing them to the appropriate backend services (FastAPI, FHIR, OpenEHR, frontend).

Why Caddy?

Automatic HTTPS

  • Zero Configuration: Automatically obtains and renews TLS certificates from Let's Encrypt
  • Always Secure: HTTPS is the default, not an afterthought
  • No Manual Renewal: Certificates are renewed automatically before expiration
  • Modern Security: Uses secure defaults and modern TLS configurations

Simple Configuration

  • Caddyfile: Human-readable configuration format
  • No Complex Syntax: Much simpler than nginx or Apache configurations
  • Sensible Defaults: Works out of the box with minimal configuration
  • Hot Reloading: Configuration changes applied without downtime

Modern Architecture

  • HTTP/2 & HTTP/3: Native support for modern protocols
  • WebSockets: Built-in WebSocket proxying
  • Reverse Proxy: Powerful reverse proxy capabilities
  • Load Balancing: Built-in load balancing and health checks

Developer Experience

  • Written in Go: Fast, single binary, no dependencies
  • Easy Deployment: Single binary makes deployment trivial
  • Excellent Documentation: Clear, comprehensive documentation
  • Active Development: Regular updates and improvements

Our Implementation

Configuration Structure

Our Caddy configuration is in caddy/dev/Caddyfile:

:80 {
  encode zstd gzip

  # API requests → backend
  @api path /api/*
  handle @api {
    reverse_proxy backend:8000
  }

  # EHRbase requests → ehrbase
  @ehrbase path /ehrbase/*
  handle @ehrbase {
    reverse_proxy ehrbase:8080
  }

  # PWA manifest with correct MIME type
  @webmanifest path /app/*.webmanifest
  header @webmanifest Content-Type "application/manifest+json"
  header @webmanifest Cache-Control "no-cache"

  # Redirect /app to /app/ for SPA routing
  redir /app /app/ 301

  # SPA served under /app and /app/*
  handle /app* {
    reverse_proxy frontend:5173
  }

  # Everything else → public pages
  handle {
    reverse_proxy frontend:5174
  }
}

Architecture Role

Caddy sits at the edge of our application stack:

Client Browser
      ↓
   Caddy (Port 80)
      ↓
      ├→ FastAPI Backend (Port 8000)    - /api/*
      ├→ EHRbase Server (Port 8080)     - /ehrbase/*
      ├→ Frontend SPA (Port 5173)       - /app/* (Vite dev server)
      └→ Public Pages (Port 5174)       - /* (marketing/landing)

Key Features in Use

Reverse Proxy

Caddy forwards requests to backend services:

# API endpoints
reverse_proxy /api/* http://backend:8000 {
    # Preserve original headers
    header_up Host {host}
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
}

# FHIR server
reverse_proxy /fhir/* http://hapi-fhir:8080 {
    # Strip /fhir prefix before forwarding
    rewrite * /fhir{path}
}

Static File Serving

Serves the React TypeScript frontend:

# Serve static files
file_server
root * /srv

# SPA fallback - all routes serve index.html
try_files {path} /index.html

CORS Handling

Manages Cross-Origin Resource Sharing:

@api path /api/*
handle @api {
    header {
        Access-Control-Allow-Origin *
        Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
        Access-Control-Allow-Headers "Content-Type, Authorization"
    }
    reverse_proxy http://backend:8000
}

Request Logging

Logs all incoming requests:

log {
    output stdout
    format json
    level INFO
}

Development vs Production

Development Configuration

caddy/dev/Caddyfile:

# No HTTPS in development
localhost:8080

# Detailed logging
log {
    level DEBUG
}

# Direct proxying to local services
reverse_proxy /api/* http://localhost:8000

Production Configuration

Production would use:

# Automatic HTTPS
quillmedical.com {
    # API backend
    reverse_proxy /api/* http://backend:8000

    # FHIR server (internal only, not exposed)
    # reverse_proxy /fhir/* http://fhir:8080

    # Frontend
    root * /srv
    file_server
    try_files {path} /index.html

    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "1; mode=block"
    }

    # Gzip compression
    encode gzip
}

Routing Strategy

API Routes (/api/*)

Forwarded to FastAPI backend:

  • /api/auth/login → Backend authentication
  • /api/patients → Backend patient endpoints
  • /api/push/* → Backend push notifications

FHIR Routes (/fhir/*)

Direct access to FHIR server (typically internal only):

  • /fhir/Patient → FHIR Patient resources
  • /fhir/Observation → FHIR Observations

Note: In production, FHIR should not be publicly exposed. Access only through backend API.

Frontend Routes (/*)

Serves React SPA:

  • / → Frontend app
  • /patients → Handled by React Router
  • /login → Handled by React Router
  • All unmatched routes → index.html (SPA fallback)

Static Assets

Served directly with caching:

  • /assets/* → JavaScript, CSS, images
  • /manifest.webmanifest → PWA manifest
  • /sw.js → Service worker

Performance Optimizations

Compression

# Gzip compression for text content
encode gzip {
    match {
        header Content-Type text/*
        header Content-Type application/json
        header Content-Type application/javascript
    }
}

Caching Headers

# Cache static assets
@static path /assets/*
handle @static {
    header Cache-Control "public, max-age=31536000, immutable"
    file_server
}

# No cache for HTML (SPA)
handle {
    header Cache-Control "no-cache, no-store, must-revalidate"
    file_server
}

Connection Pooling

Caddy automatically manages connection pools to upstream services for better performance.

Security Features

Automatic HTTPS

  • Certificates from Let's Encrypt
  • Automatic renewal 30 days before expiration
  • HTTP → HTTPS redirect
  • Modern TLS configuration (TLS 1.2+)

Security Headers

header {
    # Prevent clickjacking
    X-Frame-Options "DENY"

    # Prevent MIME sniffing
    X-Content-Type-Options "nosniff"

    # Enable XSS protection
    X-XSS-Protection "1; mode=block"

    # HSTS for HTTPS only
    Strict-Transport-Security "max-age=31536000; includeSubDomains"

    # Content Security Policy
    Content-Security-Policy "default-src 'self'"
}

Rate Limiting

# Limit request rate to prevent abuse
rate_limit {
    zone dynamic {
        key {remote_host}
        events 100
        window 1m
    }
}

Docker Integration

In compose.dev.yml:

caddy:
  image: caddy:2-alpine
  ports:
    - "8080:8080"
  volumes:
    - ./caddy/dev/Caddyfile:/etc/caddy/Caddyfile:ro
    - ./frontend/dist:/srv:ro
  depends_on:
    - backend
    - hapi-fhir

Monitoring & Debugging

Access Logs

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

Admin API

Caddy provides an admin API (default port 2019):

# Get current configuration
curl <http://localhost:2019/config/>

# Reload configuration
curl -X POST <http://localhost:2019/load> \
  -H "Content-Type: application/json" \
  -d @config.json

Health Checks

# Health check endpoint
handle /health {
    respond "OK" 200
}

Common Tasks

Reload Configuration

# Graceful reload (no downtime)
caddy reload --config /etc/caddy/Caddyfile

View Logs

# In Docker
docker compose logs caddy

# Follow logs
docker compose logs -f caddy

Test Configuration

# Validate Caddyfile syntax
caddy validate --config /etc/caddy/Caddyfile

Resources