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:
- A Dockerfile in the repo — the build definition. This is whatever your app already needs.
- A GitHub Actions workflow — triggered on push, builds the image, pushes to GHCR, then calls Coolify.
- 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:
- Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic).
- Generate a new token with
write:packagesandread:packagesscopes. - Store this token as a repository secret called
GHCR_TOKENin 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:
- Open the Coolify dashboard.
- Navigate to the application you want to deploy.
- In the app settings, find the Webhook or Deploy hook field.
- 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:
- Triggers on push to main. Adjust the branch name if you use a different default.
- Checks out the code.
- Sets up Docker Buildx for layer caching and multi-platform builds if you ever need them.
- Logs in to GHCR using the PAT stored in
GHCR_TOKEN. - Generates tags — the commit SHA (for traceability) and
latest(for Coolify to pull by default). - Builds and pushes the image with GitHub Actions cache enabled, which makes subsequent builds significantly faster.
- 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:
- Image:
ghcr.io/your-username/your-repo:latest - Docker registry: add GHCR with your PAT as the password and your GitHub username as the username.
- Auto-deploy: you can turn this off since the webhook handles triggering.
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:
- Go to Settings → Registries (or Source → Docker registries depending on your Coolify version).
- Add a new registry with host
ghcr.io. - Use the same PAT (or a new one with
read:packagesscope) 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:
- Find the last known-good commit SHA in your GitHub commit history.
- In Coolify, change the image tag from
latesttoabc1234(the SHA of the good build). - 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:
- Static HTML/CSS/JS sites — usually no build step worth a CI pipeline. Coolify pulls the repo directly and nginx serves it. The static site deploy checklist covers this simpler path.
- Apps with Dockerfiles that need compilation — Node.js bundles, Python wheels, Go binaries, multi-stage builds — GitHub Actions builds and pushes to GHCR, Coolify pulls the prebuilt image.
- Apps with complex test suites — run tests in GitHub Actions before the build step. If tests fail, the image never gets built or pushed.
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:
- Use repository secrets, not environment variables or committed values, for
GHCR_TOKENandCOOLIFY_WEBHOOK. GitHub Actions masks secrets in logs, but committed secrets are immediately compromised. - Give the PAT the minimum scopes needed.
write:packagesandread:packagesare sufficient. Do not use a token withrepooradminscope just because it works. - If your Coolify dashboard is publicly reachable, protect the deploy webhook. Coolify's webhook URLs contain a UUID that is hard to guess, but binding the Coolify dashboard to loopback and accessing it through a tunnel is a stronger posture. My VPS hardening checklist covers binding admin services to loopback.
- Rotate the PAT periodically. If it leaks, revoke it immediately from GitHub's token settings and create a new one.
The complete checklist
- Write a Dockerfile in the repo (or use the existing one).
- Create a GitHub PAT with
write:packagesandread:packages; store asGHCR_TOKEN. - Copy the Coolify deploy webhook URL; store as
COOLIFY_WEBHOOK. - Set up the Coolify app as a Docker Image source pointing at
ghcr.io/<owner>/<repo>:latest. - Add GHCR as a Docker registry in Coolify with the same PAT credentials.
- Commit
.github/workflows/deploy.ymland push to main. - Watch the GitHub Actions run: build → push → webhook → Coolify pull → container restart.
- Verify the live app serves the new version.
- 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.