From Homelab to Hetzner: Coolify, GitHub Actions, and a Zero-Downtime Deployment Pipeline
From Homelab to Hetzner
A deployment pipeline has one job: get code from a git commit to a running production server without breaking anything. Every additional feature, blue-green deployments, canary releases, feature flags, adds complexity that serves large teams shipping multiple times per day. A bootstrapped SaaS with one developer needs: push to main, tests pass, deploy. That is the pipeline this chapter builds.
The staging environment runs on the homelab, accessible via Cloudflare Tunnel. The production environment runs on a Hetzner VPS. Both use Coolify for Docker deployments. The graduation from staging to production is a DNS change and a Coolify project configuration, not a rewrite.
The Feature
A developer pushes to the main branch. GitHub Actions runs tests and linting. On success, Coolify on the Hetzner VPS pulls the updated code, builds new Docker images, performs a health check, and swaps traffic to the new containers. The old containers are stopped. Total downtime: zero.
The Decision
Hetzner CX22 for production. 2 vCPUs, 4 GB RAM, 40 GB NVMe, 20 TB traffic. Monthly cost: €4.51. This handles the FastAPI backend, Redis, and the frontend static files for hundreds of concurrent users. The database runs on Supabase (managed PostgreSQL), not on the VPS, which means the VPS does not need storage or memory for PostgreSQL.
The Hetzner CX22 was chosen over AWS, Google Cloud, and DigitalOcean for cost:
| Provider | Comparable Instance | Monthly Cost |
|---|---|---|
| Hetzner CX22 | 2 vCPU, 4 GB RAM | €4.51 |
| DigitalOcean | 2 vCPU, 4 GB RAM | $24 |
| AWS t3.medium | 2 vCPU, 4 GB RAM | ~$30 |
| Google Cloud e2-medium | 2 vCPU, 4 GB RAM | ~$25 |
Hetzner is 5-6x cheaper for equivalent compute. The trade-off is fewer managed services, which is irrelevant when using Supabase for the database and Cloudflare for DNS/CDN.
Coolify over Dokku, CapRover, or manual Docker. Coolify provides a web UI for Docker deployments, environment variable management, SSL via Let’s Encrypt or Cloudflare, and zero-downtime deploys via health checks. Dokku is similar but CLI-only. CapRover has not been updated recently. Manual Docker Compose deployments require scripting the zero-downtime swap. Coolify is free, self-hosted, and handles the deployment orchestration that would otherwise require custom shell scripts.
The Implementation
Hetzner VPS Setup
# 1. Create a CX22 instance in Hetzner Cloud Console
# - Location: Falkenstein (Germany) or Nuremberg for EU data residency
# - Image: Ubuntu 24.04
# - SSH key: Add your public key
# 2. SSH into the server
ssh root@<server-ip>
# 3. Create a non-root user
adduser deploy
usermod -aG sudo deploy
usermod -aG docker deploy
# 4. Secure SSH
sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd
# 5. Configure firewall
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Coolify Installation
# On the Hetzner VPS
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
# Coolify is now accessible at http://<server-ip>:8000
# Create an admin account and configure the instance
After installation:
- Add the Hetzner server as a “Server” in Coolify
- Create a new “Project” named “Marketflow”
- Add the GitHub repository as a “Resource” with Docker Compose deployment type
- Configure environment variables in Coolify’s UI (same as
.envbut for production)
Production Docker Compose
# docker-compose.prod.yml
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: production
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: redis://redis:6379/0
SUPABASE_URL: ${SUPABASE_URL}
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
SUPABASE_JWT_SECRET: ${SUPABASE_JWT_SECRET}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
RESEND_API_KEY: ${RESEND_API_KEY}
FRONTEND_URL: ${FRONTEND_URL}
ENVIRONMENT: production
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
redis_data:
Frontend Production Dockerfile
# frontend/Dockerfile.prod
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# frontend/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Install dependencies
working-directory: backend
run: uv sync --frozen
- name: Run linter
working-directory: backend
run: uv run ruff check .
- name: Run tests
working-directory: backend
run: uv run pytest -v
env:
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test
- name: Frontend lint and type check
working-directory: frontend
run: |
npm ci
npm run lint
npx tsc --noEmit
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Trigger Coolify deployment
run: |
curl -X POST "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}"
The deployment step is a single curl command. Coolify exposes a webhook URL for each resource. When triggered, it pulls the latest code, builds new containers, runs health checks, and swaps traffic. The complexity of Docker image building, container orchestration, and health checking is Coolify’s problem.
DNS Configuration
# Cloudflare DNS records for production
Type Name Content
A marketflow.app <hetzner-ip>
A api <hetzner-ip>
CNAME www marketflow.app
# Cloudflare settings
SSL/TLS: Full (strict)
Always Use HTTPS: On
Minimum TLS Version: 1.2
Cloudflare proxies all traffic through its network, providing DDoS protection and CDN caching. The Hetzner VPS’s IP address is not directly exposed to the internet.
The Trap
# TRAP: No health check in production Docker Compose
services:
backend:
build: ./backend
# No healthcheck defined
# Coolify deploys the new container and routes traffic immediately
# If the container starts but the app crashes during initialization,
# users see 502 errors until someone notices
# SAFE: Health check with start_period
services:
backend:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s # Wait 15s before first check, allowing app to start
# Coolify waits for the health check to pass before routing traffic
# If the health check fails, the old container continues serving
The start_period is critical. Without it, the health check runs immediately, fails because the application is still starting, and Coolify marks the deployment as failed. 15 seconds is generous for a FastAPI application. Adjust based on startup time.
The Cost
| Component | Monthly Cost |
|---|---|
| Hetzner CX22 | €4.51 |
| Coolify | $0 (self-hosted) |
| Cloudflare DNS + CDN | $0 (free tier) |
| GitHub Actions | $0 (2,000 min/month free) |
| Domain name (marketflow.app) | ~$1/month (amortized) |
Total production infrastructure cost: ~€6/month. This runs the backend, Redis, and the frontend static files. PostgreSQL runs on Supabase’s free tier. File storage runs on Cloudflare R2’s free tier. Email runs on Resend’s free tier.