Coolify GHCR Deployment

coolify docker devops github-actions

Coolify - Docker Compose Deployment

I love to jump from tool to tool to understand what’s out there and have a broader view when it comes to doing things efficiently or in a nicer way.

Today it’s Coolify, tomorrow it’s Dokploy — next week it’s something else…

To enable this flexibility, I stick to certain standards. For instance, I keep my notes in markdown files so they are portable and can be used anywhere. Similarly, as in the following example, I keep the build process out of the deployment/hosting tool.

This makes my work transferable, even if a platform decides to drop support or change licensing.

Architecture

graph LR
    Push[git push] --> GA[GitHub Actions]
    GA -->|Build & Push| GHCR[GitHub Container Registry]
    GA -->|Webhook| Coolify
    Coolify -->|Pull| GHCR
    Coolify -->|Deploy| Container

Prerequisites

  1. Coolify Instance: A running instance of Coolify.
  2. GitHub Repository: Hosting your code.
  3. Coolify Token: An API token from Coolify (User Settings -> API Tokens).

1. Container Registry (GHCR)

Instead of letting Coolify build the image, we build it in CI and push to a registry. This speeds up deployments and ensures the exact same image is tested and deployed.

Dockerfile Best Practices

  • Use multi-stage builds to keep images small.
  • Run as a non-root user for security.
  • Implement a healthcheck endpoint (e.g., /health or /).

2. GitHub Actions Workflow

Create a workflow (e.g., .github/workflows/deploy.yml) to build and push the image.

Key Steps:

  1. Log in to GHCR.
  2. Build and push the Docker image.
  3. Trigger the Coolify webhook.
# Simplified example step
- name: Trigger Coolify Deployment
  run: |
    curl --request GET '${{ vars.COOLIFY_WEBHOOK_URL }}' \
         --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'

3. Coolify Configuration

  1. Create a new Docker Compose resource.
  2. Docker Compose File:
services:
  app:
    image: ghcr.io/username/repo:latest
    pull_policy: always # CRITICAL: Pull latest image on each deploy
    container_name: my-app
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      # Magic Variable: Routes traffic to container port 8080
      - SERVICE_FQDN_APP_8080
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    labels:
      - "coolify.managed=true"

Key Configurations

  • pull_policy: always: for pre-built images. Without this, Docker Compose will reuse cached images and your deployments won’t update. This tells Docker to always pull the latest image from the registry before starting the container.
  • No Port Mapping: Do not use ports: - "host:container" unless you explicitly need to bypass the proxy. Coolify handles internal routing.
  • Magic Environment Variables: Use SERVICE_FQDN_<SERVICE_NAME>_<PORT> (e.g., SERVICE_FQDN_APP_8080) to tell Coolify’s proxy (Traefik) which internal port to route traffic to.
  • Healthchecks: valid healthchecks ensure Coolify knows when the service is ready to receive traffic. Use 127.0.0.1 inside the container.

4. Webhook Setup

  1. In Coolify Resource -> Webhooks.
  2. Copy the “Deploy” webhook URL.
  3. Add it as a variable (e.g., COOLIFY_WEBHOOK_URL) in your GitHub Repository settings. IMPORTANT: you need to set the force update option to true - when you copy the webhook URL from the coolify UI it has ?force=false at the end
  4. ensure COOLIFY_TOKEN is added as a Secret.

Examples

This pattern was successfully implemented for the blog:

  • Blog Deployment Setup (See specific implementation details)
  • Docker
  • Github Actions

Notes

I had to struggle a bit with Coolify’s Docker Compose support, as the documentation is a bit sparse. The key point is to ensure that the image is always pulled fresh from the registry on each deployment, which is achieved with the pull_policy: always setting. The force update option is still not fully clear to me, but setting it to true in the webhook URL seems to work reliably.