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.
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.
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.
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.
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.
.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.
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
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.