← Back home

2026.06 / CI/CD guide 018

GitHub Actions CI/CD for Docker Builds and Coolify Deploys

Most of the apps I run on the VPS start as a Git push. The code lands in a private GitHub repo, something builds it, and Coolify pulls the result and redeploys. For static sites the build is trivial — nginx serves HTML. For apps that need a compiled binary, a bundled frontend, or a multi-stage Docker build, the build step needs to happen somewhere with enough memory and the right toolchain.

GitHub Actions handles that build step well. The pattern I use: push to main, GitHub Actions builds the Docker image, pushes it to the GitHub Container Registry, then tells Coolify to pull and redeploy. No self-hosted runner, no separate CI server, no manual SSH into the VPS to trigger anything.

The whole pipeline is: git push → GitHub Actions builds and pushes a Docker image to GHCR → GitHub Actions calls the Coolify deploy webhook → Coolify pulls the new image and restarts the container.

This guide covers the complete workflow, the repository secrets you need, the GitHub Actions YAML, the Coolify webhook configuration, and the pitfalls that cost me time when I first set this up.

The three pieces

The pipeline has three independent parts that only communicate through a webhook call:

  1. A Dockerfile in the repo — the build definition. This is whatever your app already needs.
  2. A GitHub Actions workflow — triggered on push, builds the image, pushes to GHCR, then calls Coolify.
  3. A Coolify application — configured to pull the image from GHCR and expose it through Traefik.

If you already have Coolify running on a VPS and a private GitHub repo, most of the work is in the workflow file and two repository secrets.

The Dockerfile (if you don't have one)

This part is app-specific. A typical multi-stage build for a Node.js or Python app might look like:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production image
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

The key point is that the final stage should be as small as possible. GitHub Actions will push this to GHCR, and Coolify will pull it on each deploy. Smaller images mean faster pulls and less bandwidth on the VPS.

If your app is a static HTML site like this blog, the Dockerfile is even simpler — just nginx serving files from a copy directory.

Setting up GHCR access

The GitHub Container Registry lives at ghcr.io. To push from a GitHub Actions workflow, you need a personal access token (PAT) with write:packages scope.

Steps:

  1. Go to GitHub → SettingsDeveloper settingsPersonal access tokensTokens (classic).
  2. Generate a new token with write:packages and read:packages scopes.
  3. Store this token as a repository secret called GHCR_TOKEN in your repo.
# In your repo: Settings → Secrets and variables → Actions → New repository secret
# Name: GHCR_TOKEN
# Value: ghp_your_token_here

The workflow will use ${{ secrets.GHCR_TOKEN }} to authenticate with GHCR. The Actions runner automatically gets a GITHUB_TOKEN from the runtime, but for pushing packages to GHCR, a PAT with explicit write:packages is more reliable, especially on private repos.

The Coolify deploy webhook

Coolify can trigger a deployment when it receives a POST request to a webhook URL. This is the simplest way to connect an external CI system to a Coolify app.

To find the webhook:

  1. Open the Coolify dashboard.
  2. Navigate to the application you want to deploy.
  3. In the app settings, find the Webhook or Deploy hook field.
  4. Copy the webhook URL — it will look something like https://your-coolify-host/api/v1/deploy?uuid=....

Store this URL as another repository secret, COOLIFY_WEBHOOK:

# Settings → Secrets and variables → Actions → New repository secret
# Name: COOLIFY_WEBHOOK
# Value: https://your-coolify-host/api/v1/deploy?uuid=app-uuid

Some Coolify setups use a token-based deploy instead of a bare webhook URL. If your Coolify instance requires a Bearer token, store it as COOLIFY_TOKEN and send it in the Authorization header of the curl call.

The GitHub Actions workflow

Create the workflow at .github/workflows/deploy.yml in your repo:

name: Build and Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Trigger Coolify deploy
        run: |
          curl -fsS -X POST "${{ secrets.COOLIFY_WEBHOOK }}" \
            --max-time 30 || echo "Webhook call failed — check Coolify manually"

What this does, step by step:

  1. Triggers on push to main. Adjust the branch name if you use a different default.
  2. Checks out the code.
  3. Sets up Docker Buildx for layer caching and multi-platform builds if you ever need them.
  4. Logs in to GHCR using the PAT stored in GHCR_TOKEN.
  5. Generates tags — the commit SHA (for traceability) and latest (for Coolify to pull by default).
  6. Builds and pushes the image with GitHub Actions cache enabled, which makes subsequent builds significantly faster.
  7. Calls the Coolify webhook to trigger a pull and redeploy.

Configuring Coolify to pull from GHCR

In Coolify, the application needs to be set up as a Docker Image source, not a Git-based source, because Coolify is not building this one — GitHub Actions already built it.

In the Coolify app settings:

Coolify will pull :latest from GHCR each time the webhook fires. The SHA tag is there for rollback — if a deploy goes wrong, you can point Coolify at ghcr.io/your-username/your-repo:abc1234 in the app settings and redeploy.

What about private repos and GHCR

Images pushed to GHCR from a private repo are private by default. To let Coolify pull them, Coolify needs GHCR credentials.

In the Coolify dashboard:

  1. Go to SettingsRegistries (or SourceDocker registries depending on your Coolify version).
  2. Add a new registry with host ghcr.io.
  3. Use the same PAT (or a new one with read:packages scope) as the password.

After adding the registry, Coolify can authenticate with GHCR and pull private images. Test it once by manually triggering a deploy in the Coolify UI before relying on the automated pipeline.

Build caching makes a real difference

The cache-from and cache-to lines in the workflow enable GitHub Actions' built-in build cache. Without caching, every push rebuilds every layer from scratch — installing npm packages, compiling code, all of it. With the GitHub Actions cache backend, unchanged layers are pulled from cache in seconds and only the layers that actually changed get rebuilt.

For a typical Node.js or Python app, this turns a 3–5 minute build into a 30–60 second build on subsequent pushes. The first build will be slow regardless.

If your builds are large or you hit the 10 GB cache limit, you can switch to a different cache backend like a registry cache or a gha cache with mode=min to store fewer layers.

Handling build failures gracefully

The workflow has an important property: the Coolify webhook call only runs after the build and push succeed. If the Docker build fails, or the GHCR push fails, the webhook never fires and Coolify keeps running the last working image.

This is the right default. A failed build should not take down production. The current running container stays up until a new image is successfully pushed and the webhook confirms the deploy.

To monitor failures, enable GitHub Actions notifications (email or a Slack/Discord webhook) so you know when a build breaks without having to check manually.

Rolling back

Because each build is tagged with its commit SHA, rollback is straightforward:

  1. Find the last known-good commit SHA in your GitHub commit history.
  2. In Coolify, change the image tag from latest to abc1234 (the SHA of the good build).
  3. Redeploy.

This is why both tags exist. latest is for normal deploys. The SHA tag is for when latest broke something and you need a specific, identified version.

When to use this pattern versus Git-based Coolify deploys

Not every app needs this pipeline. Here is how I decide:

The Git-based Coolify deploy works well when the build is simple enough for the VPS to handle. The GHCR-based deploy is better when the build is heavy, needs tools that shouldn't live on the VPS, or when you want a clear separation between the build environment and the runtime.

Security notes

Keep the following in mind:

The complete checklist

  1. Write a Dockerfile in the repo (or use the existing one).
  2. Create a GitHub PAT with write:packages and read:packages; store as GHCR_TOKEN.
  3. Copy the Coolify deploy webhook URL; store as COOLIFY_WEBHOOK.
  4. Set up the Coolify app as a Docker Image source pointing at ghcr.io/<owner>/<repo>:latest.
  5. Add GHCR as a Docker registry in Coolify with the same PAT credentials.
  6. Commit .github/workflows/deploy.yml and push to main.
  7. Watch the GitHub Actions run: build → push → webhook → Coolify pull → container restart.
  8. Verify the live app serves the new version.
  9. Test a rollback by pointing Coolify at a SHA-tagged image.

That is the whole pipeline. It is three files (Dockerfile, workflow YAML, and the Coolify app config), two secrets, and one webhook. No self-hosted runners, no separate CI server, and no SSH keys on the Actions runner talking to the VPS. The VPS only talks to GHCR to pull the image, which is the same direction it already knows how to go.