From Zero to Production: Deploying a RAG Application with Docker, Caddy, and GitHub Actions
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:
- Remove stale DNS records from the domain provider
- Clear browser DNS cache (
chrome://net-internals/#dns) - 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
- SSH Key Authentication: Used ED25519 keys instead of passwords
- Environment Variables: Sensitive data stored in
.envfiles, not in the repository - CORS Configuration: Production allows only the actual domain
- 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:
- API Keys: Add OpenAI and Anthropic API keys for the chat functionality
- Monitoring: Set up UptimeRobot for the
/healthendpoint - Backups: Implement automated database backups with the included script
- Subdomain: Consider adding
www.mini-rag.deredirect
Key Takeaways
- Health checks matter: Always verify your target image includes the tools you're using
- Docker networking is different: Service names become hostnames within the compose network
- DNS debugging: Clear all levels of DNS cache when troubleshooting certificate issues
- Simplicity wins: The simplest CI/CD approach that works is often the best approach
- 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.