← Back to 2026-02-10
dockerdeploymentcicdcaddyragproductiondevops

From Zero to Production: Deploying a RAG Application with Docker, Caddy, and GitHub Actions

Oli·

From Zero to Production: Deploying a RAG Application with Docker, Caddy, and GitHub Actions

Last week, I took my MiniRAG application from development to production on a Hetzner VPS. What should have been a straightforward Docker deployment turned into a fascinating debugging session that taught me more about containerization, networking, and DNS than I expected. Here's the complete story, including the mistakes that led to the solutions.

The Goal

Deploy a Retrieval-Augmented Generation (RAG) application with:

  • Multi-container architecture: FastAPI backend, Celery workers, PostgreSQL, Redis, and Qdrant vector database
  • Automatic HTTPS via Caddy and Let's Encrypt
  • CI/CD pipeline with GitHub Actions
  • Production-ready configuration with proper secrets management

The final result is live at https://mini-rag.de with a complete automated deployment pipeline.

The Architecture

The application consists of six containerized services:

  • Caddy: Reverse proxy with automatic HTTPS
  • Web: FastAPI application server
  • Worker: Celery background task processor
  • PostgreSQL: Primary database
  • Redis: Message broker and cache
  • Qdrant: Vector database for embeddings

Lessons Learned (The Hard Way)

1. Health Checks in Minimal Docker Images

The Problem: I wanted to add health checks to ensure Qdrant was ready before starting dependent services.

# This doesn't work!
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:6333/health"]

The Reality: The official Qdrant image (qdrant/qdrant:v1.13.2) doesn't include curl or wget. Many minimal Docker images strip out common utilities to reduce size.

The Solution: Use bash's built-in TCP connectivity test:

healthcheck:
  test: ["CMD", "bash", "-c", "</dev/tcp/localhost/6333"]
  interval: 30s
  timeout: 10s
  retries: 3

This approach works in any image that includes bash and doesn't require external tools.

2. Docker Networking and Environment Variables

The Problem: My local .env file used localhost for all service connections:

DATABASE_URL=postgresql://user:pass@localhost:5432/db
REDIS_URL=redis://localhost:6379
QDRANT_URL=http://localhost:6333

The Reality: Inside Docker Compose, containers can't reach each other via localhost. Each service runs in its own container with its own localhost.

The Solution: Use Docker Compose service names as hostnames:

DATABASE_URL=postgresql://user:pass@postgres:5432/db
REDIS_URL=redis://redis:6379
QDRANT_URL=http://qdrant:6333

This was a good reminder that container networking creates its own isolated network where service names become DNS entries.

3. DNS Propagation and Certificate Issues

The Problem: After setting up my domain, HTTPS requests were failing with certificate errors:

SSL: no alternative certificate subject name matches target host name 'mini-rag.de'

The Investigation: The issue wasn't with Caddy or Let's Encrypt. Old AAAA (IPv6) records were pointing to a previous Hetzner server that was serving a wildcard certificate for *.your-server.de.

The Solution:

  1. Remove stale DNS records from the domain provider
  2. Clear browser DNS cache (chrome://net-internals/#dns)
  3. Clear system DNS cache: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder

DNS propagation issues can masquerade as application problems. Always check your DNS records when troubleshooting connectivity issues.

4. Simplifying CI/CD for Small Projects

Initial Approach: Build Docker images in GitHub Actions, push to GitHub Container Registry, then pull and deploy on the server.

The Problem: This added complexity without much benefit for a single-server deployment. The build context needs the full source code, but I was trying to keep only compose files on the server.

Simplified Solution: Clone the repository directly on the server and build there:

name: Deploy to Production
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/minirag
            git pull origin main
            docker compose -f docker-compose.prod.yml up --build -d

This approach completes deployments in about 9 seconds and is much easier to debug.

The Production Setup

Docker Compose Configuration

The production setup uses a separate docker-compose.prod.yml with optimized settings:

services:
  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
  
  web:
    build: .
    environment:
      - ENV=production
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      qdrant:
        condition: service_healthy

Automatic HTTPS with Caddy

The Caddyfile configuration is beautifully simple:

{$DOMAIN:localhost} {
    reverse_proxy web:8000
    encode gzip
}

Caddy automatically handles:

  • Let's Encrypt certificate provisioning
  • Certificate renewal
  • HTTP to HTTPS redirects
  • GZIP compression

Environment-Based Configuration

The FastAPI application reads configuration from environment variables with sensible defaults:

class Settings(BaseSettings):
    database_url: str
    redis_url: str = "redis://localhost:6379"
    qdrant_url: str = "http://localhost:6333"
    allowed_origins: str = "http://localhost:3000"
    domain: str = "localhost"
    
    class Config:
        env_file = ".env"

This allows the same codebase to work in development and production with different .env files.

Security Considerations

  1. SSH Key Authentication: Used ED25519 keys instead of passwords
  2. Environment Variables: Sensitive data stored in .env files, not in the repository
  3. CORS Configuration: Production allows only the actual domain
  4. Firewall: Only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are open

What's Next

The application is now fully deployed and operational, but there are a few remaining tasks:

  1. API Keys: Add OpenAI and Anthropic API keys for the chat functionality
  2. Monitoring: Set up UptimeRobot for the /health endpoint
  3. Backups: Implement automated database backups with the included script
  4. Subdomain: Consider adding www.mini-rag.de redirect

Key Takeaways

  1. Health checks matter: Always verify your target image includes the tools you're using
  2. Docker networking is different: Service names become hostnames within the compose network
  3. DNS debugging: Clear all levels of DNS cache when troubleshooting certificate issues
  4. Simplicity wins: The simplest CI/CD approach that works is often the best approach
  5. Environment parity: Design your configuration system to work seamlessly across environments

Deploying to production always teaches you something new about your application. The key is to embrace the debugging process and document the solutions for next time.


The complete source code for this deployment is available on GitHub, and you can see the live application at https://mini-rag.de.