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.shMulti-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"
fiCoolify 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_periodvalues - 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
healthynot justrunning - 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.