Image description
During these Easter holidays, I found myself debating whether to experiment with my self-hosted home lab setup. Spoiler alert: I did.

When I started this journey years ago, the possibilities seemed endless—even without top-tier hardware. But as my Docker services multiplied, managing them became messy. Without proper version control, backups, and orchestration, things spiraled quickly. Enter webhook-driven deployments—a flexible approach using GitHub Actions, Cloudflare Tunnels, and Portainer. Let’s dive in!


⚠️ Disclaimers

  1. Cloudflare Tunnels: This guide assumes you’ve already set up Cloudflare Tunnels to expose services remotely.
  2. Portainer Business Edition: Required for GitOps/webhook features. You can get a free license for small setups (up to 3 nodes).

Step 1: Setting Up the GitHub Repository

Start by creating a GitHub repository (private or public—sensitive data will use secrets). Clone it locally or use GitHub Codespaces for editing.

Repo setup

Example docker-compose.yml (Paperless-ngx)

services:
  broker:
    image: redis:7
    restart: unless-stopped
    volumes:
      - redisdata:/data

  db:
    image: postgres:15
    restart: unless-stopped
    volumes:
      - /mnt/sdb1/paperless-new/db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: paperless

  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    restart: unless-stopped
    depends_on:
      - db
      - broker
    ports:
      - "8000:8000"
    healthcheck:
      test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
      interval: 30s
      timeout: 10s
      retries: 5
    volumes:
      - /mnt/sdb1/paperless-new/data:/usr/src/paperless/data
      - /mnt/sdb1/paperless-new/media:/usr/src/paperless/media
      - /mnt/sdb1/export:/usr/src/paperless/export
    env_file: stack.env
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db

volumes:
  redisdata:

Step 2: GitHub Authentication for Portainer

Create a GitHub Personal Access Token

  1. Navigate to GitHub Tokens.
  2. Name the token (e.g., Portainer-GitOps) and set expiration.
  3. Grant repo permissions (read/write for private repos). GitHub token permissions
  4. Copy the token—you’ll need it for Portainer.

Step 3: Configuring Portainer Stack

  1. In Portainer, navigate to Stacks > Add Stack.
  2. Under Build Method, select Repository.
  3. Enable authentication and input:
    • Username: Your GitHub handle
    • Password: The token from Step 2
  4. Configure GitOps:
    • Repository URL: https://github.com/your-username/repo-name
    • Compose Path: docker-compose.yml (adjust if needed)
    • Enable Automatic Updates: Toggle Webhook
    • Enable Re-pull image and Redeploy when changes are pulled

Portainer stack setup

  1. Add environment variables (e.g., worker counts):
PAPERLESS_WEBSERVER_WORKERS=1
   PAPERLESS_TASK_WORKERS=1
  1. Deploy the stack!

Step 4: Cloudflare Tunnel Service Token

  1. In Cloudflare Zero Trust, go to Access > Service Auth.
  2. Create a new Service Token. Note the Client ID and Secret.
  3. Edit your Portainer application under Applications:
    • Add a Bypass policy tied to the service token. Cloudflare policy setup

Step 5: GitHub Actions Workflow

Configure Secrets in GitHub

Under repo Settings > Secrets > Actions, add:

  • PORTAINER_WEBHOOK_URL: From Portainer’s webhook setup
  • CF_ACCESS_CLIENT_ID: Cloudflare Service Token Client ID
  • CF_ACCESS_CLIENT_SECRET: Cloudflare Service Token Secret

Create the Workflow File

Add .github/workflows/deploy.yml:

name: Update Portainer Stack

on:
  push:
    branches: [main]
  workflow_dispatch:  # Manual trigger

jobs:
  update-stack:
    # Only run if commit message contains [deploy]
    if: contains(github.event.head_commit.message, '[deploy]')
    runs-on: ubuntu-latest

    steps:
      - name: Trigger Portainer Webhook
        env:
          PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }}
          CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
          CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
        run: |
          curl -X POST "$PORTAINER_WEBHOOK_URL" \
            -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
            -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"

Step 6: Testing the Pipeline

  1. Commit changes with [deploy] in the message:
git commit -m "chore: update compose [deploy]"
  1. Push to trigger the action: GitHub Actions success

Portainer will now redeploy your stack automatically! Failed deployments roll back gracefully, and you can reuse the repo as a template for future projects.


Final Thoughts

This setup brings GitOps practices to self-hosting—version control, CI/CD, and secure access. Suggestions? Let me know! 🚀