logo
HomeArticlesThoughtsProjects

Push-to-Deploy with GitHub Actions, Docker Compose, and Cloudflare Tunnel

19 days ago

My side projects run on one Linux server. Pushing to main deploys them. There's no container registry, and no inbound ports are open for web traffic.

Four parts: a Cloudflare Tunnel container for ingress, a shared Docker network so the tunnel can reach the apps, a deploy script on the server, and a GitHub Actions workflow that runs the script over SSH.

git push main ──▶ GitHub Actions ──ssh──▶ deploy.sh
                                          ├─ git fetch + reset --hard origin/main
                                          └─ docker compose up -d --build

visitors ──▶ Cloudflare edge ──▶ cloudflared container ──▶ app container
                                 (outbound-only tunnel)    (shared Docker network)

Prerequisites: a server with Docker and the Compose plugin, a domain on Cloudflare, and the repo cloned on the server. This article uses a deploy user with the repo at /home/deploy/myapp; a private repo needs a deploy key. Names and paths are examples.

1. Shared Docker network

Create it once:

docker network create tunnel-net

Both compose files declare it external: true: attach to the existing network instead of creating one per project.

2. Cloudflare Tunnel container

Create a tunnel in the Cloudflare Zero Trust dashboard (Networks → Tunnels) and copy its token. The tunnel gets its own directory.

/home/deploy/cf-tunnels/docker-compose.yml:

services:
  tunnel-1:
    container_name: CF-Tunnel-1
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN_1}
    networks:
      - tunnel-net

networks:
  tunnel-net:
    external: true

/home/deploy/cf-tunnels/.env:

TUNNEL_TOKEN_1=eyJhbGciOi...

Start it with docker compose up -d from that directory.

cloudflared connects out to Cloudflare and keeps that connection open; requests for your hostnames come back down it. No service listens for web traffic on the server, TLS terminates at Cloudflare's edge, and the origin IP stays out of DNS.

3. Apps join the network

Each project has its own compose file. To be reachable, it only needs the network:

services:
  app:
    # build, environment, volumes...
    networks:
      - tunnel-net

networks:
  tunnel-net:
    external: true

There's no ports: section; the container never binds a host port. In the Cloudflare dashboard, add a public hostname to the tunnel (myapp.example.com → http://app:3000). Docker's internal DNS resolves the service name app because both containers are on tunnel-net. One tunnel can front several projects this way; keep service names unique on the shared network, since they share a DNS namespace.

4. Deploy script

scripts/deploy.sh, versioned in the repo:

#!/usr/bin/env bash
set -euo pipefail

REPO_DIR=/home/deploy/myapp
BRANCH=main
STATE_FILE=$REPO_DIR/scripts/.state/myapp.rev

cd "$REPO_DIR"
git fetch origin "$BRANCH"

target=$(git rev-parse "origin/$BRANCH")
deployed=$(cat "$STATE_FILE" 2>/dev/null || echo none)

if [ "$deployed" = "$target" ]; then
    echo "$(date -Is) already at $target, nothing to do"
    exit 0
fi

echo "$(date -Is) deploying $target"
git reset --hard "$target"
docker compose up -d --build --remove-orphans
docker image prune -f
mkdir -p "$(dirname "$STATE_FILE")"
echo "$target" > "$STATE_FILE"
echo "$(date -Is) deploy OK"

Make it executable once and commit; git tracks the executable bit, so every clone keeps it:

chmod +x scripts/deploy.sh

The state file holds the SHA of the last deploy. If origin/main hasn't moved, the script exits immediately, so it's idempotent: safe to re-run by hand or trigger from cron as a fallback.

git reset --hard treats the server checkout as disposable. Nothing is edited there, so deploys can't hit merge conflicts. It only touches tracked files, which is why the untracked .state directory survives (add scripts/.state/ to .gitignore).

docker compose up -d --build --remove-orphans rebuilds from the fresh checkout and recreates only containers whose image or config changed. --remove-orphans removes services deleted from the compose file, and docker image prune -f clears dangling layers.

5. GitHub Actions workflow

.github/workflows/deploy.yml:

name: Deploy
on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          script: /home/deploy/myapp/scripts/deploy.sh

It runs on every push to main; workflow_dispatch adds a manual trigger. The concurrency group queues overlapping runs instead of letting them race, and cancel-in-progress: false lets a running deploy finish. The script's output appears in the Actions log, so every run doubles as a deploy record.

Three repository secrets (Settings → Secrets and variables → Actions): SSH_HOST , SSH_USERNAME , and SSH_KEY.

6. SSH key for the workflow

On the server, as the deploy user:

ssh-keygen -t ed25519 -f ~/.ssh/github-actions-myapp -C "github-actions-myapp" -N ""
cat ~/.ssh/github-actions-myapp.pub >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
cat ~/.ssh/github-actions-myapp
rm ~/.ssh/github-actions-myapp*

This generates a passphrase-less ed25519 key, authorizes its public half, prints the private key to paste into the SSH_KEY secret, then deletes both files. After the rm, the private key exists only in GitHub's secret store and the public half only in authorized_keys.

Optional hardening: restrict the key in ~/.ssh/authorized_keys so it can only run the deploy script:

command="/home/deploy/myapp/scripts/deploy.sh",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... github-actions-myapp

Gotchas

Run deploy.sh by hand once before wiring up the workflow. Debugging git or Docker problems is easier in a shell than through Actions logs.

git reset --hard rewrites the script while it's running, and bash reads scripts lazily. A script this short is fine in practice; if it grows, wrap the body in main() { ... }; main "$@" so bash parses the whole file before executing.

To roll back, git revert the bad commit and push. The pipeline deploys the previous state like any other change.

SSH is the only inbound port left. To close it too, move SSH behind Tailscale or Cloudflare Access; the rest of the pipeline is unchanged.

The script reports success when the containers start, not when the app works. A curl against a health endpoint at the end of the script closes that gap.

Contents

  • 1. Shared Docker network
  • 2. Cloudflare Tunnel container
  • 3. Apps join the network
  • 4. Deploy script
  • 5. GitHub Actions workflow
  • 6. SSH key for the workflow
  • Gotchas