Skip to main content

Self-Hosting a Production App: Debian, Docker, Cloudflare Tunnel, and GitHub Actions CI/CD

Updated 25 min readMarkdown
Self-Hosting a Production App: Debian, Docker, Cloudflare Tunnel, and GitHub Actions CI/CD

I wanted a deploy pipeline where git push on main ends with my app running on my own hardware, over HTTPS, with a real CI/CD build, and with zero open ports on my router. Not a VPS bill, not ngrok, not "SSH in and pull manually when I remember to."

This post is the complete build log for the setup I landed on: a Debian home server, Docker as the runtime, GitHub Actions as CI/CD, GHCR as the image registry, and Cloudflare Tunnel + Cloudflare Access doing double duty as both the networking layer and the auth layer. I'm going deep on the why behind each piece, not just the what — and I'm including the real failures I hit along the way, in the order I hit them, because that's usually the part a "clean" tutorial edits out and it's the part that actually saves you time.

If you've never touched Cloudflare Tunnel or Access before, the single most important idea in this whole post is this one, so I'll say it up front: a home server with no open inbound ports can still serve public HTTPS traffic, because the server initiates an outbound connection to Cloudflare and Cloudflare routes traffic back down that same connection. Nothing "listens" on the public internet. Everything below is really just working out the details of that one idea.

Why this stack, instead of the obvious alternatives

Before the how, it's worth being explicit about the trade-offs, because none of these choices are free:

  • VPS instead of home server — simpler networking (you get a public IP), but you're paying monthly for compute you may already own, and you inherit someone else's hardware failure modes.

  • Port forwarding instead of Cloudflare Tunnel — works, but it means poking a hole in your router firewall, exposing your home IP, and manually managing TLS certificates (Let's Encrypt renewal, etc.). One misconfigured iptables rule and your home network is reachable from the internet.

  • ngrok/similar instead of Cloudflare Tunnel — great for a demo, but you're on someone else's random subdomain, often with session limits, and it's not really built for "this is my production deployment target."

  • Manual docker pull instead of GitHub Actions — fine at first, terrible the moment you forget which commit is actually deployed, or you're on your phone and can't SSH in.

Cloudflare Tunnel plus GitHub Actions solves the two hardest parts of self-hosting — inbound networking and repeatable deployment — without needing a static IP, router configuration, or a human sitting at a terminal every time you ship a change.

The concepts, briefly (with analogies)

If any of these are new, here's the short version of each, in plain terms, before we wire them together:

  • SSH is a locked front door with two matching keys cut at the same time — one key (private) stays with you, the other (public) gets installed in the lock. Nobody can pick this lock without the matching private key; a password isn't even in the picture.

  • DNS is a phone book. ssh.arnabsamanta.in isn't an address by itself — DNS is what tells the internet which "phone number" (in this case, which Cloudflare tunnel) that name actually points to.

  • Docker image vs. container — the image is a sealed shipping container with your app and everything it needs packed inside; a running container is that same box, opened up and actually operating.

  • GHCR (GitHub Container Registry) is a warehouse for those shipping containers — GitHub Actions drops a new one off after every build, and your server picks up whichever one it's told to.

  • Cloudflare Tunnel is a phone line your server dials out on, that Cloudflare keeps open. Because the server made the call, nobody from outside needs to be able to call in — there's no number for them to dial.

  • Cloudflare Access is the bouncer standing at the other end of that phone line, checking ID before letting anyone through to the server — whether "anyone" is you in a browser, or a robot (GitHub Actions) that can't show a photo ID and needs a different kind of credential entirely.

  • GitHub Actions is a temporary, disposable computer that GitHub spins up for a few minutes every time you push code, runs your build/deploy steps on, and then throws away.

Keep these mental models in your back pocket — every failure later in this post is really just one of these seven ideas breaking down at a specific step.

The full picture

GitHub push (main)
        |
        v
GitHub Actions builds Docker image
        |
        v
Image pushed to GHCR  (tags: latest, <commit-sha>)
        |
        v
GitHub Actions SSHes in via cloudflared (used as an SSH ProxyCommand)
        |
        v
Cloudflare Access authenticates the runner  (non-interactive service token)
        |
        v
Cloudflare Tunnel forwards the SSH connection -> Debian localhost:22
        |
        v
SSH public-key auth logs in as `arnab`
        |
        v
docker compose pull   (pulls the exact new image tag)
docker compose up -d  (recreates the container)
        |
        v
App running on localhost:3000
        |
        v
Cloudflare Tunnel exposes it publicly over HTTPS (separate ingress rule)

And separately, the public-facing traffic path (this one has nothing to do with GitHub — it's what happens every time someone visits the site):

Visitor's browser
        |  HTTPS request to portfolio.arnabsamanta.in
        v
Cloudflare edge  (terminates TLS here — the container never sees a certificate)
        |
        v
Cloudflare Tunnel  (matches the hostname against an ingress rule)
        |
        v
Debian server, localhost:3000
        |
        v
Docker container (the Next.js app)

Two completely separate flows share the same tunnel: one carries SSH traffic for deployment, the other carries HTTP traffic for real visitors. Neither knows the other exists — they're just different ingress rules on the same physical outbound connection.

Prerequisites

  • A Debian (or Ubuntu Server) machine that stays powered on and has a stable internet connection — the tunnel needs a persistent outbound connection to Cloudflare's edge

  • A domain managed through Cloudflare's DNS, plus a Cloudflare Zero Trust account (this is what gives you Access policies and service tokens — a plain Cloudflare account without Zero Trust enabled won't have this)

  • A GitHub repository with a production-ready Dockerfile that builds and runs your app correctly in a container

  • Docker Engine and Docker Compose installed on the server

  • OpenSSH server running on the server, reachable on localhost:22

  • cloudflared installed on the server (the daemon that maintains the tunnel connection)

  • A dedicated, non-root Linux user for deployments

  • A dedicated SSH key pair generated specifically for CI/CD — don't reuse your personal laptop's key here; if it's ever exposed, you want to be able to revoke a single-purpose key without touching your own access

If Linux users and permissions, SSH key mechanics, or the difference between a Docker image and a running container are genuinely brand-new to you, it's worth spending twenty minutes getting comfortable with those in isolation first. Everything below assumes you know roughly what each piece does on its own and is focused on how they connect.

Step 1 — Prepare the Debian user

Everything in this pipeline runs as a non-root user, arnab, added to the docker group so it can run containers without needing sudo for every Docker command:

# create the user if it doesn't exist yet
sudo useradd -m -s /bin/bash arnab

# add it to the docker group (GID 989 on this box — yours may differ)
sudo usermod -aG docker arnab

# log out/in (or start a new shell) for the group membership to apply, then verify:
sudo -u arnab docker ps

If that last command returns a container list (even an empty one) instead of a permission error, the user genuinely has Docker access.

This is worth pausing on, because it's easy to wave past: membership in the docker group is effectively root-equivalent on that machine. Any process that can start an arbitrary container can bind-mount / from the host into that container and read or write anything on the filesystem as root. There's no meaningful sandbox between "can run docker" and "has root." If you're deciding who or what gets this group membership, decide it with the same seriousness you'd apply to handing out the root password — because functionally, that's what you're doing.

Step 2 — SSH key-based access

Two things need to exist for key-based login to work: the directory structure with the right permissions, and the public key installed inside it.

# on your local machine, generate a dedicated deploy key (skip if you already have one)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key

# on the server, as arnab:
mkdir -p /home/arnab/.ssh
chmod 700 /home/arnab/.ssh

# paste the CONTENTS of deploy_key.pub into this file:
vim /home/arnab/.ssh/authorized_keys
chmod 600 /home/arnab/.ssh/authorized_keys

The permissions aren't cosmetic — OpenSSH will silently refuse to use authorized_keys if the permissions are looser than expected (e.g., group- or world-writable), as a defense against a compromised account on the same machine tampering with another user's keys. 700 on the directory and 600 on the file are the values sshd expects; loosen them and key auth will fail with no obvious error pointing at why.

The rule underneath all of this: the private key never touches the server, ever. Only the public half — the one that's safe to hand out — goes into authorized_keys. The private half (deploy_key, no extension) goes into a GitHub Secret named SSH_PRIVATE_KEY, and it never leaves GitHub's encrypted secret store except to be injected into the Actions runner's memory at job time.

Test it locally before wiring up any automation:

ssh -i ./deploy_key arnab@<server-ip> "echo it works"

Get this working over a plain local network connection first. Don't debug SSH key auth and Cloudflare Access at the same time — that's two independent failure surfaces, and conflating them is exactly how you end up chasing the wrong problem for an hour.

Step 3 — Cloudflare Tunnel: the outbound-only connection

cloudflared runs as a background service on the Debian box and opens a persistent outbound connection to Cloudflare's edge — typically over HTTP/2 or QUIC. Because the connection originates from inside your network, there's nothing for an attacker (or you) to "connect to" from outside. Your router's firewall doesn't need a single new rule.

Create the tunnel and get its credentials file:

cloudflared tunnel login
cloudflared tunnel create debian-server
# this writes a credentials JSON file and prints a tunnel ID (a UUID)

Then tell Cloudflare's DNS to route your hostnames to that tunnel — this creates CNAME records pointing at <tunnel-id>.cfargotunnel.com:

cloudflared tunnel route dns debian-server ssh.arnabsamanta.in

And configure what the tunnel actually forwards to, locally, per hostname — this is the ingress file, and it's the single source of truth for "which hostname goes to which local service":

/root/.cloudflared/config.yml:

tunnel: debian-server
credentials-file: /root/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: cockpit.arnabsamanta.in
    service: https://localhost:9090
    originRequest:
      noTLSVerify: true   # cockpit's local cert is self-signed
  - hostname: netdata.arnabsamanta.in
    service: http://localhost:19999
  - hostname: ssh.arnabsamanta.in
    service: ssh://localhost:22
  - service: http_status:404   # required catch-all — anything unmatched gets a 404

Ingress rules are evaluated top to bottom, first match wins, which is exactly like a switch statement falling through to a default case. Skip the catch-all and unmatched hostnames behave unpredictably instead of failing cleanly.

Run and enable the service, then validate the config before trusting any of it:

sudo systemctl enable --now cloudflared
cloudflared tunnel ingress validate
cloudflared tunnel info debian-server

ingress validate catches YAML mistakes and unreachable local services before they turn into a 2am debugging session over a broken production hostname.

Step 4 — Cloudflare Access: the part that actually trips people up

Everything up to here is "how does traffic get to the server." This step is "who's allowed to." And it's the part of this whole build that cost me the most time, because a human and a CI runner need to authenticate in genuinely different ways, even though both are, mechanically, just "SSHing in."

Human:
  Laptop -> ssh.arnabsamanta.in -> Cloudflare Access
         -> (browser opens, log in with email/SSO)
         -> Tunnel -> Debian:22 -> SSH key auth -> arnab

GitHub Actions:
  Runner -> cloudflared ProxyCommand -> Cloudflare Access
         -> (service token, presented as headers — no browser, no human)
         -> Tunnel -> Debian:22 -> SSH key auth -> arnab

In the Cloudflare Zero Trust dashboard, this means creating one Access application (ssh, type "self-hosted," hostname ssh.arnabsamanta.in) with two separate policies attached to it:

  1. An email/identity policy — "allow if the authenticated identity's email is mine." This is what triggers the interactive browser login for me personally.

  2. A Service Auth policy — "allow if the request presents a valid, specific service token." No identity provider involved, no browser, nothing interactive. This is built specifically for machines.

The service token itself is a Client ID / Client Secret pair generated in the dashboard. It becomes two more GitHub Secrets: CF_SERVICE_TOKEN_ID and CF_SERVICE_TOKEN_SECRET. When cloudflared access ssh runs on the GitHub runner, it presents these as HTTP headers (CF-Access-Client-Id / CF-Access-Client-Secret) as part of establishing the tunnel connection — Cloudflare's edge validates them against the Service Auth policy before ever letting the connection reach your server.

The mental model worth keeping permanently: Cloudflare Access decides who's allowed to reach the hostname. The SSH key decides who they're allowed to log in as, once they're there. These are two independent gates in series. A perfectly valid SSH key is useless if Access won't let the connection through in the first place. A valid Access session doesn't skip Linux's own login process either. Both gates have to open, every single time, and they're checking two completely different things.

Step 5 — The GitHub Actions pipeline

The workflow splits cleanly into two jobs: build (produce and publish the image) and deploy (get that image running on the server). Splitting them means a build failure never even attempts a deploy, and you get a clear "which half failed" signal in the Actions UI.

Build job

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3
        # buildx enables multi-platform builds and layer caching;
        # not strictly required for a single-arch build, but worth having

      - name: Log in to GHCR
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
        # GITHUB_TOKEN is auto-generated per run by GitHub itself —
        # you never set this as a secret, it just exists for the job's duration

      - name: Build and push
        run: |
          docker build \
            -t ghcr.io/learner-enthusiast/portfolio2.0:latest \
            -t ghcr.io/learner-enthusiast/portfolio2.0:${{ github.sha }} \
            .
          docker push ghcr.io/learner-enthusiast/portfolio2.0:latest
          docker push ghcr.io/learner-enthusiast/portfolio2.0:${{ github.sha }}

Tagging with both latest and the commit SHA isn't redundant — latest is a convenience pointer that's genuinely useful for humans poking around GHCR, but ${{ github.sha }} is what the deploy job actually pulls, because it's immutable. latest can silently mean something different tomorrow than it means today; a SHA tag can't. This is the difference between "the current version" and "this exact, specific, provable version" — and for anything you might ever need to roll back, you want the latter.

Deploy job

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Install cloudflared
        run: |
          curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
          chmod +x cloudflared
          sudo mv cloudflared /usr/local/bin

      - name: Write deploy key
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > deploy_key
          chmod 600 deploy_key
          # written fresh into the runner's ephemeral workspace —
          # this filesystem is destroyed when the job ends

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          cat >> ~/.ssh/config <<EOF
          Host homeserver
            HostName ${{ secrets.SSH_HOST }}
            User ${{ secrets.SSH_USER }}
            IdentityFile ${{ github.workspace }}/deploy_key
            ProxyCommand cloudflared access ssh --hostname %h \
              --service-token-id ${{ secrets.CF_SERVICE_TOKEN_ID }} \
              --service-token-secret ${{ secrets.CF_SERVICE_TOKEN_SECRET }}
          EOF
          # ProxyCommand hands the raw SSH byte stream to cloudflared,
          # which wraps it through Cloudflare Access before it ever hits the tunnel

      - name: Verify SSH connection
        run: ssh homeserver "echo connected"
        # fail loudly here, before anything depends on this connection working

      - name: Upload compose file
        run: scp docker-compose.prod.yml homeserver:${{ secrets.DEPLOY_PATH }}/

      - name: Deploy
        run: |
          ssh homeserver "
            cd ${{ secrets.DEPLOY_PATH }} &&
            echo IMAGE_TAG=${{ github.sha }} >> .env &&
            docker compose -f docker-compose.prod.yml pull &&
            docker compose -f docker-compose.prod.yml up -d --remove-orphans &&
            docker image prune -f
          "

      - name: Health check
        run: ssh homeserver "curl -fsS http://localhost:3000/"

A few details worth calling out explicitly:

  • ProxyCommand is a standard OpenSSH client feature — it hands off the raw connection to an arbitrary external program instead of opening a normal TCP socket. cloudflared access ssh is that program here; from OpenSSH's point of view, it's just tunneling bytes over stdin/stdout, completely unaware that Cloudflare Access is sitting in the middle.

  • --remove-orphans cleans up any containers defined in a previous version of the compose file that no longer exist in the current one — useful the moment you rename or remove a service.

  • docker image prune -f matters more than it looks like it should. Every deploy pulls a brand-new SHA-tagged image; without pruning, old dangling images accumulate on a home server with finite disk space until you eventually get a very unglamorous "no space left on device" failure mid-deploy.

  • The health check is deliberately the last step, and it's checking localhost:3000 on the server itself — not the public hostname. That's on purpose: it isolates "does the container actually work" from "is Cloudflare's edge routing to it correctly," which are two different failure domains you don't want conflated in one check.

Step 6 — Compose file and environment variable strategy

docker-compose.prod.yml defines the service and reads its image tag from .env:

services:
  portfolio:
    image: ghcr.io/learner-enthusiast/portfolio2.0:${IMAGE_TAG}
    container_name: portfolio
    ports:
      - "0.0.0.0:3000:3000"
    restart: unless-stopped

restart: unless-stopped means Docker restarts the container automatically after a crash or a host reboot, but won't fight you if you deliberately stop it — a reasonable middle ground for a home server that occasionally loses power.

Why does the server "know" which image to pull on any given deploy? It doesn't decide anything on its own — the server is purely an executor, never the source of truth. GitHub Actions writes IMAGE_TAG=<commit-sha> into the server's .env file on every single deploy; docker compose pull then just fetches whatever tag .env currently happens to say. All the actual decision-making — what to build, what to tag it, when to ship it — happens in CI. The server's only job is to faithfully run whatever it's told to run.

On environment variables more broadly, the split that actually matters is CI/deploy credentials versus runtime application secrets — two categories that are easy to accidentally blend together:

Category Examples Lives in Who reads it
CI/deploy credentials SSH_PRIVATE_KEY, CF_SERVICE_TOKEN_SECRET, GHCR_TOKEN GitHub Secrets (encrypted at rest, injected only at job runtime) The Actions runner, during the workflow
Runtime app secrets DATABASE_URL, JWT_SECRET Server-side .env, read by Compose The application container, at startup
Frontend build-time config VITE_API_URL, other public build flags Can be committed / non-secret Baked into the browser JS bundle

That last row is the one people get wrong most often. Anything compiled into a React, Vite, or Next.js client bundle is visible to literally anyone who opens their browser's dev tools and looks at the network tab or the bundled JS. Naming a variable SECRET_API_KEY doesn't make it secret if it ends up shipped to every visitor's browser — the naming convention is not the security boundary, where the value physically ends up is.

For a multi-service setup, each Compose service can point at its own env_file, keeping a Node backend's DATABASE_URL/JWT_SECRET conceptually and physically separate from a React frontend's public build config, even if both services live in the same repo and the same docker-compose.prod.yml.

Step 7 — Exposing the app publicly

Once the container is healthy and answering on localhost:3000, going public is one more ingress rule plus one DNS route — nothing about the deploy pipeline changes:

cloudflared tunnel route dns debian-server portfolio.arnabsamanta.in
  - hostname: portfolio.arnabsamanta.in
    service: http://localhost:3000
Visitor -> HTTPS -> Cloudflare edge (TLS terminated here)
        -> Tunnel ingress match
        -> Debian localhost:3000
        -> Docker container

Cloudflare terminates TLS at its own edge, using a certificate it manages — the container behind the tunnel only ever speaks plain HTTP on localhost, and never has to think about certificates, renewal, or anything TLS-related at all. That entire concern is fully absorbed by Cloudflare, for every hostname on the tunnel, for free.

What actually went wrong (and why it's worth reading)

The architecture above reads cleanly because I already know how it ends. Almost none of it worked on the first attempt, and honestly, the failures taught me more about how these pieces actually fit together than getting it right on the first try would have. Here they are, in the order I hit them — because the order failures show up in is often a better debugging map than the finished diagram.

Missing authorized_keys. The Linux account existed and could run Docker fine, but there was no ~/.ssh directory on it at all — nobody had ever installed a public key for that user. SSH key auth obviously can't succeed against a directory that doesn't exist; it'll just silently fall through to password auth (or refuse outright, depending on your sshd_config). Lesson: verify the account genuinely has a key installed before spending time debugging anything further down the chain — Cloudflare, the tunnel, the workflow — none of that matters if the most basic prerequisite was never actually true.

The server's DNS resolver was broken. Early on, basic lookups were failing — deb.debian.org and login.cloudflareaccess.org both came back with "temporary failure in name resolution." This looked like it had to be a Cloudflare-specific problem, since one of the failing hostnames was literally Cloudflare's. It wasn't. It was a broken resolver at the OS level, unrelated to anything Cloudflare or Docker were doing. The general lesson generalizes well beyond this one incident: always confirm the most boring possible explanation (can this machine resolve any hostname at all?) before reaching for a more interesting, more specific one.

websocket: bad handshake / UNKNOWN port 65535. This error message sent me down a long path — checking the tunnel status, the DNS record, the cloudflared systemd unit, cloudflared tunnel info, ingress validation, and the actual SSH listener on the server (ss -lntp confirmed it was listening correctly on port 22). All of that came back clean. The SSH daemon was never actually the problem; the failure was happening one layer earlier, somewhere in the Cloudflare Access handshake itself, before the connection ever reached OpenSSH. The error message pointed at the symptom's location, not its cause — a distinction that's easy to lose twenty minutes into a debugging session.

A browser popped up in the middle of a headless CI run. This is the failure that mattered most conceptually, and it's the one I'd flag hardest for anyone attempting this build. Running cloudflared access ssh with the default Access identity policy assumes there's a human present who can complete a browser-based login. A GitHub-hosted runner has no browser, no display, and no human sitting there to click "allow" — so the flow just hangs, waiting for an interaction that will never come. The fix isn't a clever workaround; it's recognizing that machine authentication and human authentication are fundamentally different problems that happen to share a UI ("SSH into this server"). The actual solution was a completely separate, non-identity Service Auth Access policy, built from the ground up for exactly this — no browser, no human, just a token.

CF_SERVICE_TOKEN_ID came back empty in a debug step. The secret genuinely existed, correctly, in the repository's GitHub Secrets settings. But it wasn't mapped into the workflow's env: block under the exact name the shell script was expecting — a small naming mismatch, invisible unless you go looking for it. Secret names are literal string identifiers, not fuzzy references; a workflow referencing ${{ secrets.CF_TOKEN_ID }} when the repo secret is actually named CF_SERVICE_TOKEN_ID fails silently, producing an empty string rather than an error. (And when adding a debug step to investigate this kind of thing, only ever check presence and length — echo "length: ${#TOKEN}" — never print the actual value, even temporarily, even in a run you're about to delete.)

scp looked stuck, with no error, during "Copy production compose file." No timeout, no error message — it just sat there indefinitely. scp is built entirely on top of SSH; if the SSH connection's ProxyCommand can't successfully authenticate through Cloudflare Access, scp has no working transport to even attempt the file copy on, so it just hangs waiting for a connection that will never establish. The eventual fix was as much procedural as technical: add an explicit "Verify SSH connection" step before the scp step, so a broken connection fails loudly and immediately, at the layer it actually broke at, instead of manifesting as a mysterious hang three steps later that looks like an scp problem. The generalizable lesson: test the dependency chain in the order things actually depend on each other — Cloudflare Access, then SSH, then scp, then Docker — rather than jumping straight to whichever step happens to be showing the visible symptom.

Could the SSH key replace the Cloudflare service token, or the other way around? Worth stating explicitly, because it's a genuinely reasonable question to have: no. The SSH key authenticates the Linux user (arnab) to OpenSSH. The Cloudflare service token authenticates the GitHub runner itself to Cloudflare Access. These are answering two entirely different questions — "who is this machine, as far as Cloudflare is concerned" versus "who is this user, as far as Linux is concerned" — and collapsing them into one mental category is exactly how the Access-policy confusion above happens in the first place. Both layers are required, both are checking something the other one doesn't, and neither is a substitute for the other.

Security notes

A short list, easy to skip over and expensive to actually skip:

  • Never commit SSH private keys, service token secrets, or GHCR credentials to the repository — not even to a private one, and not even "temporarily."

  • Keep production secrets out of docker-compose.prod.yml entirely. Reference them via .env and env_file, don't inline actual values into a file that's likely tracked in Git.

  • Prefer immutable commit-SHA image tags for anything you deploy. latest alone can't tell you, six months from now, exactly what code was actually running in production at a given moment.

  • Apply least privilege everywhere a credential exists — the GHCR token, the Cloudflare service token, the SSH key — each should be scoped to exactly what it needs and nothing more.

  • Rotate a credential immediately the moment it's suspected of being exposed, rather than waiting to confirm the exposure first. The cost of rotating unnecessarily is minutes; the cost of not rotating a genuinely exposed credential is unbounded.

  • Treat docker group membership as root-equivalent, because functionally, it is — see Step 1.

Runbook

Before deploying: server online and reachable, DNS resolving correctly, cloudflared service active, tunnel ingress validated, arnab can run docker ps without sudo, SSH key auth verified working, the CI Service Auth Access policy exists and is attached to the right application, and every required GitHub Secret is actually set (not just referenced in the workflow).

During deploy: watch the Actions run live rather than assuming it worked, confirm the build job genuinely finished successfully before the deploy job starts (this is what needs: build enforces), verify the SSH connection explicitly as its own step before attempting scp, then let the pipeline pull the new image, recreate the container, and run its health check.

After deploying:

docker ps
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs --tail=100
curl http://localhost:3000/

And finally, a check against the actual public hostname — not just localhost — since a passing local health check only proves the container works, not that Cloudflare is routing to it correctly.

Where this leaves things, and what's next

Working end to end, right now: build → GHCR push → Cloudflare-tunneled SSH → compose pull/up → local health check, with a Next.js portfolio container serving real traffic on port 3000 behind portfolio.arnabsamanta.in.

A few natural next steps from here, roughly in the order I'd tackle them: adding a public Cloudflare hostname for each new app as it gets containerized, rather than repeating this whole setup from scratch each time; keeping frontend build-time/public config cleanly separated from backend runtime secrets as more services join the same box; adding a real rollback path — redeploy the previous SHA automatically — for when a health check fails post-deploy, instead of just alerting on it after the fact; and, once more than one or two applications are sharing this server, putting an actual reverse proxy in front instead of hand-managing one Cloudflare ingress rule per service.

None of the individual pieces here are exotic on their own — SSH keys, Docker, a CI pipeline, a tunnel. What actually made this work was less about any single technology and more about getting the order of operations right: DNS has to work before Cloudflare does anything useful, Cloudflare Access has to succeed before SSH gets a chance to, SSH has to succeed before scp can move a single byte, and a green build has to exist before a deploy job is allowed to assume one does. Almost every failure above was really just that ordering breaking down at one specific link — never the whole chain at once.

No comments yet. Be the first to comment.