Testing with Coolify: Self-Hosted Deployment Verification and Docker Testing

Testing with Coolify: Self-Hosted Deployment Verification and Docker Testing

Coolify has emerged as the leading self-hosted alternative to platforms like Heroku and Render. It runs on your own servers — a single Hetzner VPS, a bare-metal box, or a small cluster — and gives you Heroku-style deployment workflows without the per-seat pricing or data sovereignty concerns. For teams with compliance requirements, cost sensitivity, or specific infrastructure needs, Coolify is increasingly the platform of choice.

But self-hosted means you own the reliability problem. When something breaks on Heroku, it's their incident. When something breaks on Coolify, it's yours. That makes testing strategy more important, not less. This guide covers how to build a robust testing pipeline for Coolify-deployed applications.

Understanding Coolify's Architecture

Coolify runs as a Docker-based control plane on your server. It manages application deployments using Docker (single containers), Docker Compose (multi-container apps), or Nixpacks for buildpack-style deploys. Each application has its own service definition, and Coolify handles routing via Traefik (bundled) or Caddy.

When testing Coolify deployments, you're working with:

  • Docker health checks — container-level checks that determine if a container is healthy
  • Coolify health checks — application-level HTTP checks configured in the Coolify UI
  • Traefik routing — HTTP routing with automatic TLS via Coolify's proxy
  • Coolify API — programmatic access for deployment status, triggering deploys, and querying service health
  • Webhook triggers — incoming webhooks that Coolify exposes per-application for CI-triggered deploys

Docker Compose Health Check Configuration

The foundation of Coolify service testing is Docker's native health check system. Define health checks in your docker-compose.yml, and Docker will track container health state, which Coolify can use to determine if a deployment succeeded.

Here's a production-grade Docker Compose configuration with health checks:

# docker-compose.yml
version: '3.8'

services:
  api:
    image: your-registry/api:${IMAGE_TAG:-latest}
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=redis://redis:6379
      - NODE_ENV=production
    ports:
      - "8080"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.yourdomain.com`)"
      - "traefik.http.routers.api.tls.certresolver=letsencrypt"
      - "traefik.http.services.api.loadbalancer.server.port=8080"

  postgres:
    image: postgres:15-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    volumes:
      - redis_data:/data

  worker:
    image: your-registry/api:${IMAGE_TAG:-latest}
    command: node dist/worker.js
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  postgres_data:
  redis_data:

The start_period is important for Coolify deployments — it tells Docker not to count health check failures during the initial startup window, preventing false failures during cold starts.

Using the Coolify API for Deployment Status

Coolify exposes a REST API that lets you query deployment status, trigger deployments, and manage services programmatically. This is your bridge from CI to Coolify.

#!/bin/bash
# coolify-api.sh — common Coolify API operations

COOLIFY_URL="${COOLIFY_URL:-https://coolify.yourdomain.com}"
COOLIFY_TOKEN="${COOLIFY_TOKEN}"

# List all applications
list_apps() {
  curl -s \
    -H "Authorization: Bearer $COOLIFY_TOKEN" \
    "$COOLIFY_URL/api/v1/applications"
}

# Get deployment status for an application
get_app_status() {
  local app_uuid="$1"
  curl -s \
    -H "Authorization: Bearer $COOLIFY_TOKEN" \
    "$COOLIFY_URL/api/v1/applications/$app_uuid"
}

# Get recent deployments for an application
get_deployments() {
  local app_uuid="$1"
  curl -s \
    -H "Authorization: Bearer $COOLIFY_TOKEN" \
    "$COOLIFY_URL/api/v1/deployments?application_uuid=$app_uuid"
}

# Trigger a deployment
trigger_deploy() {
  local app_uuid="$1"
  curl -s -X POST \
    -H "Authorization: Bearer $COOLIFY_TOKEN" \
    -H "Content-Type: application/json" \
    "$COOLIFY_URL/api/v1/deploy?uuid=$app_uuid&force=false"
}

# Wait for deployment to complete
wait_for_deploy() {
  local app_uuid="$1"
  local timeout="${2:-300}"
  local elapsed=0

  echo "Waiting for deployment to complete (timeout: ${timeout}s)..."

  while [ $elapsed -lt $timeout ]; do
    DEPLOYMENT=$(get_deployments "$app_uuid" | jq -r '.[0]')
    STATUS=$(echo "$DEPLOYMENT" | jq -r '.status')

    case "$STATUS" in
      "finished")
        echo "Deployment finished successfully"
        return 0
        ;;
      "failed"|"error")
        echo "Deployment failed: $STATUS"
        echo "Last deployment details:"
        echo "$DEPLOYMENT" | jq '{status, logs}'
        return 1
        ;;
      "in_progress"|"queued")
        echo "  Status: $STATUS (${elapsed}s elapsed)"
        sleep 10
        elapsed=$((elapsed + 10))
        ;;
      *)
        echo "  Unknown status: $STATUS"
        sleep 10
        elapsed=$((elapsed + 10))
        ;;
    esac
  done

  echo "Timeout waiting for deployment"
  return 1
}

Webhook Triggers in CI Pipelines

Coolify generates a unique webhook URL for each application. When this URL receives a POST request, Coolify triggers a new deployment. This is the primary CI integration mechanism.

Here's a complete GitHub Actions workflow using Coolify webhooks:

# .github/workflows/deploy.yml
name: Build, Test, Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: --health-cmd pg_isready --health-interval 10s --health-retries 5
      redis:
        image: redis:7
        options: --health-cmd "redis-cli ping" --health-interval 10s
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
        env:
          DATABASE_URL: postgres://postgres:test@localhost/testdb
          REDIS_URL: redis://localhost:6379

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Coolify deployment
        run: |
          RESPONSE=$(curl -s -X POST \
            "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{"image_tag": "${{ needs.build-and-push.outputs.image_tag }}"}')
          echo "Webhook response: $RESPONSE"

      - name: Wait for deployment health
        run: |
          APP_URL="${{ secrets.APP_URL }}"
          MAX_ATTEMPTS=30

          for i in $(seq 1 $MAX_ATTEMPTS); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$APP_URL/health")
            if [ "$STATUS" = "200" ]; then
              echo "Application healthy after $i attempts"
              break
            fi
            echo "Attempt $i: HTTP $STATUS"
            sleep 10
          done

          if [ "$i" = "$MAX_ATTEMPTS" ] && [ "$STATUS" != "200" ]; then
            echo "Application never became healthy"
            exit 1
          fi

      - name: Run smoke tests
        run: ./scripts/smoke-tests.sh
        env:
          APP_URL: ${{ secrets.APP_URL }}

Verifying Docker Container Health States

After Coolify completes a deployment, you should verify that all containers are actually in a healthy state — not just running, but passing their health checks. This requires SSH access to your Coolify server.

#!/bin/bash
# verify-docker-health.sh
# Run this on the Coolify server (via SSH from CI, or directly)

COMPOSE_PROJECT="${COMPOSE_PROJECT:-myapp}"
REQUIRED_HEALTHY=("api" "worker")
FAILED=0

echo "=== Docker Container Health Verification ==="
echo "Project: $COMPOSE_PROJECT"
echo ""

# Get health status for all containers in project
docker ps --filter "name=${COMPOSE_PROJECT}" \
  --format "table {{.Names}}\t{{.Status}}\t{{.Health}}"

echo ""

# Check specific services that must be healthy
for service in "${REQUIRED_HEALTHY[@]}"; do
  CONTAINER_NAME=$(docker ps --filter "name=${COMPOSE_PROJECT}_${service}" --format "{{.Names}}" | head -1)

  if [ -z "$CONTAINER_NAME" ]; then
    echo "  FAIL: Container ${COMPOSE_PROJECT}_${service} not found"
    FAILED=$((FAILED + 1))
    continue
  fi

  HEALTH=$(docker inspect "$CONTAINER_NAME" --format "{{.State.Health.Status}}" 2>/dev/null)

  case "$HEALTH" in
    "healthy")
      echo "  PASS: $service is healthy"
      ;;
    "unhealthy")
      echo "  FAIL: $service is unhealthy"
      # Show last health check output
      docker inspect "$CONTAINER_NAME" \
        --format "{{range .State.Health.Log}}{{.Output}}{{end}}" | tail -3
      FAILED=$((FAILED + 1))
      ;;
    "starting")
      echo "  WARN: $service is still starting (check again in 30s)"
      ;;
    "")
      echo "  INFO: $service has no health check configured"
      ;;
    *)
      echo "  UNKNOWN: $service health status: $HEALTH"
      ;;
  esac
done

echo ""
if [ $FAILED -gt 0 ]; then
  echo "Container health verification FAILED ($FAILED failures)"
  exit 1
fi
echo "All containers healthy"

For CI pipelines that need remote verification, run this via SSH:

# In your GitHub Actions workflow
- name: Verify container health on Coolify server
  run: |
    ssh -i ${{ secrets.SSH_PRIVATE_KEY }} \
      -o StrictHostKeyChecking=no \
      deploy@your-coolify-server.com \
      "COMPOSE_PROJECT=myapp bash -s" < ./scripts/verify-docker-health.sh

Multi-App Testing Strategies

One of Coolify's advantages is running multiple applications on a single server. This is economical, but it means a problem with one app can affect others — shared resources, network conflicts, or Traefik routing issues.

Here's a comprehensive multi-app health check script:

#!/bin/bash
# multi-app-health.sh — check all Coolify-managed applications

COOLIFY_URL="${COOLIFY_URL}"
COOLIFY_TOKEN="${COOLIFY_TOKEN}"
FAILED=0

echo "=== Multi-App Health Check ==="

# Get all applications from Coolify API
APPS=$(curl -s \
  -H "Authorization: Bearer $COOLIFY_TOKEN" \
  "$COOLIFY_URL/api/v1/applications" | jq -r '.[] | "\(.uuid) \(.name) \(.fqdn)"')

while IFS=' ' read -r uuid name fqdn; do
  echo ""
  echo "App: $name ($fqdn)"

  # Get Coolify's view of the application status
  APP_DATA=$(curl -s \
    -H "Authorization: Bearer $COOLIFY_TOKEN" \
    "$COOLIFY_URL/api/v1/applications/$uuid")

  STATUS=$(echo "$APP_DATA" | jq -r '.status')
  echo "  Coolify status: $STATUS"

  # Check HTTP health if FQDN is set
  if [ -n "$fqdn" ] && [ "$fqdn" != "null" ]; then
    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
      --max-time 10 "https://$fqdn/health")

    if [ "$HTTP_STATUS" = "200" ]; then
      echo "  HTTP health: PASS ($HTTP_STATUS)"
    elif [ "$HTTP_STATUS" = "000" ]; then
      echo "  HTTP health: UNREACHABLE (connection failed)"
      FAILED=$((FAILED + 1))
    else
      echo "  HTTP health: WARN (HTTP $HTTP_STATUS)"
    fi
  fi
done <<< "$APPS"

echo ""
if [ $FAILED -gt 0 ]; then
  echo "Multi-app health check FAILED: $FAILED apps unreachable"
  exit 1
fi
echo "Multi-app health check passed"

Testing Traefik Routing Configuration

Coolify uses Traefik as its reverse proxy. Routing misconfiguration is a common source of deployment issues — a typo in a label, duplicate router names, or TLS certificate failures. Test your Traefik routing explicitly:

#!/bin/bash
# test-traefik-routing.sh

TRAEFIK_API="${TRAEFIK_API:-http://localhost:8080}"  # Traefik API (not exposed publicly)
APP_URL="${APP_URL}"

echo "=== Traefik Routing Tests ==="

# Check Traefik is accessible
TRAEFIK_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TRAEFIK_API/api/version")
if [ "$TRAEFIK_STATUS" != "200" ]; then
  echo "WARN: Traefik API not accessible — skipping routing checks"
else
  # List all HTTP routers
  echo "Active HTTP routers:"
  curl -s "$TRAEFIK_API/api/http/routers" | \
    jq -r '.[] | "\(.name) → \(.rule) [\(.status)]"' | \
    grep -v "@internal"

  # Check for error states
  ERRORED=$(curl -s "$TRAEFIK_API/api/http/routers" | \
    jq -r '[.[] | select(.status == "disabled" or .status == "error")] | length')

  if [ "$ERRORED" -gt 0 ]; then
    echo "FAIL: $ERRORED router(s) in error/disabled state"
    curl -s "$TRAEFIK_API/api/http/routers" | \
      jq -r '.[] | select(.status == "disabled" or .status == "error") | "\(.name): \(.status)"'
    exit 1
  fi

  echo "PASS: All routers healthy"
fi

# Test TLS certificate is valid
echo ""
echo "Checking TLS certificate..."
CERT_INFO=$(echo | openssl s_client -connect "${APP_URL#https://}:443" -servername "${APP_URL#https://}" 2>/dev/null | \
  openssl x509 -noout -dates 2>/dev/null)

if [ -n "$CERT_INFO" ]; then
  echo "$CERT_INFO"
  # Check cert isn't expiring in next 7 days
  EXPIRY=$(echo | openssl s_client -connect "${APP_URL#https://}:443" 2>/dev/null | \
    openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
  EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || date -jf "%b %e %T %Y %Z" "$EXPIRY" +%s 2>/dev/null)
  NOW_EPOCH=$(date +%s)
  DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

  if [ "$DAYS_LEFT" -lt 7 ]; then
    echo "FAIL: TLS certificate expires in $DAYS_LEFT days"
    exit 1
  fi
  echo "PASS: Certificate valid for $DAYS_LEFT more days"
else
  echo "WARN: Could not check TLS certificate"
fi

Coolify Self-Hosted vs Cloud Testing Considerations

When you self-host with Coolify, you take on infrastructure concerns that cloud platforms handle for you. Your testing strategy needs to include server-level health checks that wouldn't apply to Heroku or Render:

#!/bin/bash
# server-health.sh — self-hosted infrastructure checks

echo "=== Server Infrastructure Health ==="

# Disk space (deployments fail silently when disk is full)
DISK_USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
echo "Disk usage: ${DISK_USAGE}%"
if [ "$DISK_USAGE" -gt 85 ]; then
  echo "  WARN: Disk usage above 85% — Docker builds may fail"
fi

# Docker disk usage
echo ""
echo "Docker disk usage:"
docker system df

# Clean up if needed
if [ "$DISK_USAGE" -gt 90 ]; then
  echo "Running Docker cleanup..."
  docker system prune -f --filter "until=72h"
fi

# Memory pressure
TOTAL_MEM=$(free -m | awk '/Mem:/ {print $2}')
USED_MEM=$(free -m | awk '/Mem:/ {print $3}')
MEM_PERCENT=$((USED_MEM * 100 / TOTAL_MEM))
echo ""
echo "Memory: ${USED_MEM}MB / ${TOTAL_MEM}MB (${MEM_PERCENT}%)"

if [ "$MEM_PERCENT" -gt 90 ]; then
  echo "  WARN: Memory usage critical — containers may OOM"
fi

# Coolify service itself
COOLIFY_RUNNING=$(docker ps --filter "name=coolify" --format "{{.Names}}" | grep -c "coolify")
echo ""
echo "Coolify containers running: $COOLIFY_RUNNING"
if [ "$COOLIFY_RUNNING" -lt 1 ]; then
  echo "  FAIL: Coolify is not running"
  exit 1
fi

echo ""
echo "Server health check complete"

Continuous Monitoring for Self-Hosted Apps

Self-hosted deployments on Coolify are especially vulnerable to gradual degradation — the server load increases, memory leaks accumulate, disk fills up — without anyone watching. Continuous monitoring is more important for self-hosted deployments than for cloud platforms.

HelpMeTest provides this continuous monitoring layer. Its Playwright-based tests run full user journeys on a schedule, catching issues that health check endpoints can't see: broken forms, failed API calls, slow page loads, and third-party integration failures. For teams running Coolify on their own infrastructure, HelpMeTest acts as an external observer that isn't affected by server-side issues — if your Coolify server is struggling, HelpMeTest's tests will show it even if your server-local health checks pass.

At $100/month for unlimited tests and parallel execution, it's a practical external monitoring layer for self-hosted infrastructure that would otherwise require you to build and maintain your own monitoring stack.

Complete Coolify Testing Pipeline

Combining all the pieces above:

#!/bin/bash
# full-coolify-test.sh — complete verification pipeline

set -e

COOLIFY_URL="${COOLIFY_URL}"
COOLIFY_TOKEN="${COOLIFY_TOKEN}"
APP_UUID="${APP_UUID}"
APP_URL="${APP_URL}"

echo "======================================"
echo "Coolify Deployment Verification"
echo "======================================"
echo ""

# Step 1: Verify Coolify itself is healthy
echo "Step 1: Coolify API health"
COOLIFY_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $COOLIFY_TOKEN" \
  "$COOLIFY_URL/api/v1/healthcheck")

if [ "$COOLIFY_HEALTH" != "200" ]; then
  echo "FAIL: Coolify API unreachable (HTTP $COOLIFY_HEALTH)"
  exit 1
fi
echo "PASS: Coolify API healthy"

# Step 2: Check application status in Coolify
echo ""
echo "Step 2: Application status"
APP_STATUS=$(curl -s \
  -H "Authorization: Bearer $COOLIFY_TOKEN" \
  "$COOLIFY_URL/api/v1/applications/$APP_UUID" | jq -r '.status')
echo "Application status: $APP_STATUS"

# Step 3: HTTP health check
echo ""
echo "Step 3: Application HTTP health"
for i in {1..5}; do
  HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$APP_URL/health")
  [ "$HTTP_STATUS" = "200" ] && break
  echo "  Attempt $i: HTTP $HTTP_STATUS"
  sleep 5
done

if [ "$HTTP_STATUS" != "200" ]; then
  echo "FAIL: Application health check failed"
  exit 1
fi
echo "PASS: HTTP health check passed"

# Step 4: Deep health check
echo ""
echo "Step 4: Deep service health"
DEEP=$(curl -s "$APP_URL/health/deep")
DEEP_STATUS=$(echo "$DEEP" | jq -r '.status')
echo "Deep health: $(echo "$DEEP" | jq -c '.checks')"

if [ "$DEEP_STATUS" != "ok" ]; then
  echo "FAIL: Deep health check failed"
  exit 1
fi
echo "PASS: All services healthy"

# Step 5: Smoke tests
echo ""
echo "Step 5: Smoke tests"
./scripts/smoke-tests.sh

echo ""
echo "======================================"
echo "All verification steps passed"
echo "======================================"

Summary

Coolify gives you powerful self-hosted deployment capabilities, but self-hosted means you own the reliability. Build your testing strategy around these layers:

  • Docker health checks — configure them for every service in your Compose file with appropriate start_period values
  • Coolify API polling — use the API to verify deployment status programmatically from CI
  • Webhook-triggered deploys — use Coolify's per-app webhooks as your CI-to-deployment bridge
  • Container state verification — after deploy, confirm Docker reports containers as healthy not just running
  • Traefik routing tests — verify your reverse proxy configuration is actually routing traffic correctly
  • Server-level health — self-hosted means watching disk, memory, and Docker resource usage
  • External monitoring — use HelpMeTest or similar for continuous visibility that's independent of your server's health

With this approach, Coolify's self-hosted model stops being a reliability risk and starts being a genuine advantage — full control over your infrastructure, with the testing confidence to match.

Read more

Start now free