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
<span class="hljs-comment"># coolify-api.sh — common Coolify API operations

COOLIFY_URL=<span class="hljs-string">"${COOLIFY_URL:-https://coolify.yourdomain.com}"
COOLIFY_TOKEN=<span class="hljs-string">"${COOLIFY_TOKEN}"

<span class="hljs-comment"># List all applications
<span class="hljs-function">list_apps() {
  curl -s \
    -H <span class="hljs-string">"Authorization: Bearer $COOLIFY_TOKEN" \
    <span class="hljs-string">"$COOLIFY_URL/api/v1/applications"
}

<span class="hljs-comment"># Get deployment status for an application
<span class="hljs-function">get_app_status() {
  <span class="hljs-built_in">local app_uuid=<span class="hljs-string">"$1"
  curl -s \
    -H <span class="hljs-string">"Authorization: Bearer $COOLIFY_TOKEN" \
    <span class="hljs-string">"$COOLIFY_URL/api/v1/applications/<span class="hljs-variable">$app_uuid"
}

<span class="hljs-comment"># Get recent deployments for an application
<span class="hljs-function">get_deployments() {
  <span class="hljs-built_in">local app_uuid=<span class="hljs-string">"$1"
  curl -s \
    -H <span class="hljs-string">"Authorization: Bearer $COOLIFY_TOKEN" \
    <span class="hljs-string">"$COOLIFY_URL/api/v1/deployments?application_uuid=<span class="hljs-variable">$app_uuid"
}

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

<span class="hljs-comment"># Wait for deployment to complete
<span class="hljs-function">wait_for_deploy() {
  <span class="hljs-built_in">local app_uuid=<span class="hljs-string">"$1"
  <span class="hljs-built_in">local <span class="hljs-built_in">timeout=<span class="hljs-string">"${2:-300}"
  <span class="hljs-built_in">local elapsed=0

  <span class="hljs-built_in">echo <span class="hljs-string">"Waiting for deployment to complete (timeout: ${timeout}s)..."

  <span class="hljs-keyword">while [ <span class="hljs-variable">$elapsed -lt <span class="hljs-variable">$timeout ]; <span class="hljs-keyword">do
    DEPLOYMENT=$(get_deployments <span class="hljs-string">"$app_uuid" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.[0]')
    STATUS=$(<span class="hljs-built_in">echo <span class="hljs-string">"$DEPLOYMENT" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.status')

    <span class="hljs-keyword">case <span class="hljs-string">"$STATUS" <span class="hljs-keyword">in
      <span class="hljs-string">"finished")
        <span class="hljs-built_in">echo <span class="hljs-string">"Deployment finished successfully"
        <span class="hljs-built_in">return 0
        <span class="hljs-pipe">;;
      <span class="hljs-string">"failed"|<span class="hljs-string">"error")
        <span class="hljs-built_in">echo <span class="hljs-string">"Deployment failed: $STATUS"
        <span class="hljs-built_in">echo <span class="hljs-string">"Last deployment details:"
        <span class="hljs-built_in">echo <span class="hljs-string">"$DEPLOYMENT" <span class="hljs-pipe">| jq <span class="hljs-string">'{status, logs}'
        <span class="hljs-built_in">return 1
        <span class="hljs-pipe">;;
      <span class="hljs-string">"in_progress"|<span class="hljs-string">"queued")
        <span class="hljs-built_in">echo <span class="hljs-string">"  Status: $STATUS (<span class="hljs-variable">${elapsed}s elapsed)"
        <span class="hljs-built_in">sleep 10
        elapsed=$((elapsed + <span class="hljs-number">10))
        <span class="hljs-pipe">;;
      *)
        <span class="hljs-built_in">echo <span class="hljs-string">"  Unknown status: $STATUS"
        <span class="hljs-built_in">sleep 10
        elapsed=$((elapsed + <span class="hljs-number">10))
        <span class="hljs-pipe">;;
    <span class="hljs-keyword">esac
  <span class="hljs-keyword">done

  <span class="hljs-built_in">echo <span class="hljs-string">"Timeout waiting for deployment"
  <span class="hljs-built_in">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
<span class="hljs-comment"># verify-docker-health.sh
<span class="hljs-comment"># Run this on the Coolify server (via SSH from CI, or directly)

COMPOSE_PROJECT=<span class="hljs-string">"${COMPOSE_PROJECT:-myapp}"
REQUIRED_HEALTHY=(<span class="hljs-string">"api" <span class="hljs-string">"worker")
FAILED=0

<span class="hljs-built_in">echo <span class="hljs-string">"=== Docker Container Health Verification ==="
<span class="hljs-built_in">echo <span class="hljs-string">"Project: $COMPOSE_PROJECT"
<span class="hljs-built_in">echo <span class="hljs-string">""

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

<span class="hljs-built_in">echo <span class="hljs-string">""

<span class="hljs-comment"># Check specific services that must be healthy
<span class="hljs-keyword">for service <span class="hljs-keyword">in <span class="hljs-string">"${REQUIRED_HEALTHY[@]}"; <span class="hljs-keyword">do
  CONTAINER_NAME=$(docker ps --filter <span class="hljs-string">"name=${COMPOSE_PROJECT}_<span class="hljs-variable">${service}" --format <span class="hljs-string">"{{.Names}}" <span class="hljs-pipe">| <span class="hljs-built_in">head -1)

  <span class="hljs-keyword">if [ -z <span class="hljs-string">"$CONTAINER_NAME" ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"  FAIL: Container ${COMPOSE_PROJECT}_<span class="hljs-variable">${service} not found"
    FAILED=$((FAILED + <span class="hljs-number">1))
    <span class="hljs-built_in">continue
  <span class="hljs-keyword">fi

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

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

<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-keyword">if [ <span class="hljs-variable">$FAILED -gt 0 ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"Container health verification FAILED ($FAILED failures)"
  <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"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: <span class="hljs-pipe">|
    ssh -i <span class="hljs-variable">${{ secrets.SSH_PRIVATE_KEY }} \
      -o StrictHostKeyChecking=no \
      deploy@your-coolify-server.com \
      <span class="hljs-string">"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
<span class="hljs-comment"># multi-app-health.sh — check all Coolify-managed applications

COOLIFY_URL=<span class="hljs-string">"${COOLIFY_URL}"
COOLIFY_TOKEN=<span class="hljs-string">"${COOLIFY_TOKEN}"
FAILED=0

<span class="hljs-built_in">echo <span class="hljs-string">"=== Multi-App Health Check ==="

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

<span class="hljs-keyword">while IFS=<span class="hljs-string">' ' <span class="hljs-built_in">read -r uuid name fqdn; <span class="hljs-keyword">do
  <span class="hljs-built_in">echo <span class="hljs-string">""
  <span class="hljs-built_in">echo <span class="hljs-string">"App: $name (<span class="hljs-variable">$fqdn)"

  <span class="hljs-comment"># Get Coolify's view of the application status
  APP_DATA=$(curl -s \
    -H <span class="hljs-string">"Authorization: Bearer $COOLIFY_TOKEN" \
    <span class="hljs-string">"$COOLIFY_URL/api/v1/applications/<span class="hljs-variable">$uuid")

  STATUS=$(<span class="hljs-built_in">echo <span class="hljs-string">"$APP_DATA" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.status')
  <span class="hljs-built_in">echo <span class="hljs-string">"  Coolify status: $STATUS"

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

    <span class="hljs-keyword">if [ <span class="hljs-string">"$HTTP_STATUS" = <span class="hljs-string">"200" ]; <span class="hljs-keyword">then
      <span class="hljs-built_in">echo <span class="hljs-string">"  HTTP health: PASS ($HTTP_STATUS)"
    <span class="hljs-keyword">elif [ <span class="hljs-string">"$HTTP_STATUS" = <span class="hljs-string">"000" ]; <span class="hljs-keyword">then
      <span class="hljs-built_in">echo <span class="hljs-string">"  HTTP health: UNREACHABLE (connection failed)"
      FAILED=$((FAILED + <span class="hljs-number">1))
    <span class="hljs-keyword">else
      <span class="hljs-built_in">echo <span class="hljs-string">"  HTTP health: WARN (HTTP $HTTP_STATUS)"
    <span class="hljs-keyword">fi
  <span class="hljs-keyword">fi
<span class="hljs-keyword">done <<< <span class="hljs-string">"$APPS"

<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-keyword">if [ <span class="hljs-variable">$FAILED -gt 0 ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"Multi-app health check FAILED: $FAILED apps unreachable"
  <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"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
<span class="hljs-comment"># test-traefik-routing.sh

TRAEFIK_API=<span class="hljs-string">"${TRAEFIK_API:-http://localhost:8080}"  <span class="hljs-comment"># Traefik API (not exposed publicly)
APP_URL=<span class="hljs-string">"${APP_URL}"

<span class="hljs-built_in">echo <span class="hljs-string">"=== Traefik Routing Tests ==="

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

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

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

  <span class="hljs-built_in">echo <span class="hljs-string">"PASS: All routers healthy"
<span class="hljs-keyword">fi

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

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

  <span class="hljs-keyword">if [ <span class="hljs-string">"$DAYS_LEFT" -lt 7 ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: TLS certificate expires in $DAYS_LEFT days"
    <span class="hljs-built_in">exit 1
  <span class="hljs-keyword">fi
  <span class="hljs-built_in">echo <span class="hljs-string">"PASS: Certificate valid for $DAYS_LEFT more days"
<span class="hljs-keyword">else
  <span class="hljs-built_in">echo <span class="hljs-string">"WARN: Could not check TLS certificate"
<span class="hljs-keyword">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
<span class="hljs-comment"># server-health.sh — self-hosted infrastructure checks

<span class="hljs-built_in">echo <span class="hljs-string">"=== Server Infrastructure Health ==="

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

<span class="hljs-comment"># Docker disk usage
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Docker disk usage:"
docker system <span class="hljs-built_in">df

<span class="hljs-comment"># Clean up if needed
<span class="hljs-keyword">if [ <span class="hljs-string">"$DISK_USAGE" -gt 90 ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"Running Docker cleanup..."
  docker system prune -f --filter <span class="hljs-string">"until=72h"
<span class="hljs-keyword">fi

<span class="hljs-comment"># Memory pressure
TOTAL_MEM=$(free -m <span class="hljs-pipe">| awk <span class="hljs-string">'/Mem:/ {print $2}')
USED_MEM=$(free -m <span class="hljs-pipe">| awk <span class="hljs-string">'/Mem:/ {print $3}')
MEM_PERCENT=$((USED_MEM * <span class="hljs-number">100 / TOTAL_MEM))
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Memory: ${USED_MEM}MB / <span class="hljs-variable">${TOTAL_MEM}MB (<span class="hljs-variable">${MEM_PERCENT}%)"

<span class="hljs-keyword">if [ <span class="hljs-string">"$MEM_PERCENT" -gt 90 ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"  WARN: Memory usage critical — containers may OOM"
<span class="hljs-keyword">fi

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

<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"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
<span class="hljs-comment"># full-coolify-test.sh — complete verification pipeline

<span class="hljs-built_in">set -e

COOLIFY_URL=<span class="hljs-string">"${COOLIFY_URL}"
COOLIFY_TOKEN=<span class="hljs-string">"${COOLIFY_TOKEN}"
APP_UUID=<span class="hljs-string">"${APP_UUID}"
APP_URL=<span class="hljs-string">"${APP_URL}"

<span class="hljs-built_in">echo <span class="hljs-string">"======================================"
<span class="hljs-built_in">echo <span class="hljs-string">"Coolify Deployment Verification"
<span class="hljs-built_in">echo <span class="hljs-string">"======================================"
<span class="hljs-built_in">echo <span class="hljs-string">""

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

<span class="hljs-keyword">if [ <span class="hljs-string">"$COOLIFY_HEALTH" != <span class="hljs-string">"200" ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Coolify API unreachable (HTTP $COOLIFY_HEALTH)"
  <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: Coolify API healthy"

<span class="hljs-comment"># Step 2: Check application status in Coolify
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Step 2: Application status"
APP_STATUS=$(curl -s \
  -H <span class="hljs-string">"Authorization: Bearer $COOLIFY_TOKEN" \
  <span class="hljs-string">"$COOLIFY_URL/api/v1/applications/<span class="hljs-variable">$APP_UUID" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.status')
<span class="hljs-built_in">echo <span class="hljs-string">"Application status: $APP_STATUS"

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

<span class="hljs-keyword">if [ <span class="hljs-string">"$HTTP_STATUS" != <span class="hljs-string">"200" ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Application health check failed"
  <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: HTTP health check passed"

<span class="hljs-comment"># Step 4: Deep health check
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Step 4: Deep service health"
DEEP=$(curl -s <span class="hljs-string">"$APP_URL/health/deep")
DEEP_STATUS=$(<span class="hljs-built_in">echo <span class="hljs-string">"$DEEP" <span class="hljs-pipe">| jq -r <span class="hljs-string">'.status')
<span class="hljs-built_in">echo <span class="hljs-string">"Deep health: $(echo "$DEEP" <span class="hljs-pipe">| jq -c '.checks')"

<span class="hljs-keyword">if [ <span class="hljs-string">"$DEEP_STATUS" != <span class="hljs-string">"ok" ]; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Deep health check failed"
  <span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: All services healthy"

<span class="hljs-comment"># Step 5: Smoke tests
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Step 5: Smoke tests"
./scripts/smoke-tests.sh

<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"======================================"
<span class="hljs-built_in">echo <span class="hljs-string">"All verification steps passed"
<span class="hljs-built_in">echo <span class="hljs-string">"======================================"

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