Skip to main content
ship before you scale

From Homelab to Hetzner: Coolify, GitHub Actions, and a Zero-Downtime Deployment Pipeline

7 min read Chapter 25 of 42

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:

ProviderComparable InstanceMonthly Cost
Hetzner CX222 vCPU, 4 GB RAM€4.51
DigitalOcean2 vCPU, 4 GB RAM$24
AWS t3.medium2 vCPU, 4 GB RAM~$30
Google Cloud e2-medium2 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:

  1. Add the Hetzner server as a “Server” in Coolify
  2. Create a new “Project” named “Marketflow”
  3. Add the GitHub repository as a “Resource” with Docker Compose deployment type
  4. Configure environment variables in Coolify’s UI (same as .env but 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

ComponentMonthly 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.