<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vinicius Ornelas</title>
    <description>The latest articles on DEV Community by Vinicius Ornelas (@vini-ornelas).</description>
    <link>https://dev.to/vini-ornelas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3376137%2Fccf28ec6-a6e5-44a0-8570-23888f21c57f.jpg</url>
      <title>DEV Community: Vinicius Ornelas</title>
      <link>https://dev.to/vini-ornelas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vini-ornelas"/>
    <language>en</language>
    <item>
      <title>Securing a CI/CD Pipeline Behind NAT with Tailscale</title>
      <dc:creator>Vinicius Ornelas</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:07:52 +0000</pubDate>
      <link>https://dev.to/vini-ornelas/securing-a-cicd-pipeline-behind-nat-with-tailscale-4jn6</link>
      <guid>https://dev.to/vini-ornelas/securing-a-cicd-pipeline-behind-nat-with-tailscale-4jn6</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5g7wfomn15te8hbcz6a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5g7wfomn15te8hbcz6a.png" alt="Post cover" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Spring Boot REST API running on a home server behind NAT, deployed via GitHub Actions through a self-hosted runner installed on the same production machine. It worked for months — until a security audit flagged it as &lt;strong&gt;critical severity&lt;/strong&gt;. The Docker socket on that runner meant any workflow step could own the server. A fork PR on a public repo could execute arbitrary code on production.&lt;/p&gt;

&lt;p&gt;This article covers two problems solved simultaneously: how to harden a CI/CD pipeline on a public repository, and how to deploy to a server with no public IP from ephemeral GitHub-hosted runners. The migration took ~8 hours across 2 days, with a 2-week observation period before decommissioning the old runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a self-hosted runner on a public repo is a ticking bomb
&lt;/h2&gt;

&lt;p&gt;The project is a solo-maintained API serving production traffic through Cloudflare tunnels. The CI/CD pipeline ran on a &lt;strong&gt;self-hosted GitHub Actions runner installed on the same machine that hosted the application&lt;/strong&gt;. For months, this was convenient: no network hop for deploys, direct Docker socket access, zero latency.&lt;/p&gt;

&lt;p&gt;Then I read the &lt;a href="https://www.sysdig.com/blog/how-threat-actors-are-using-self-hosted-github-actions-runners-as-backdoors" rel="noopener noreferrer"&gt;Shai-Hulud writeup&lt;/a&gt; and realized what I had built: a remote code execution endpoint on my production server, accessible to anyone who could open a pull request.&lt;/p&gt;

&lt;p&gt;Here's the risk matrix the audit produced:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker socket = root on host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker run --privileged -v /:/hostfs alpine chroot /hostfs&lt;/code&gt; gives full root access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence between jobs&lt;/td&gt;
&lt;td&gt;A malicious workflow can install cron jobs, modify &lt;code&gt;~/.bashrc&lt;/code&gt;, or alter the runner binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No network isolation&lt;/td&gt;
&lt;td&gt;Runner sits on the home LAN with no VLAN separation — lateral movement to other devices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public repo + self-hosted runner&lt;/td&gt;
&lt;td&gt;Any fork can open a PR and execute code on the runner — zero-click RCE for external attackers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets persisted on disk&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; files and JWT keys remain after deploy — credentials exposed if server is compromised&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://www.stepsecurity.io/blog/evolving-harden-runners-disable-sudo-policy-for-improved-runner-security" rel="noopener noreferrer"&gt;CVE-2025-32955&lt;/a&gt; disclosure — showing that &lt;code&gt;disable-sudo&lt;/code&gt; in Harden-Runner could be bypassed via Docker — confirmed this wasn't theoretical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix was clear:&lt;/strong&gt; move to GitHub-hosted runners (ephemeral VMs, destroyed after each job, no Docker socket exposure). But my server had no public IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NAT problem: how do you SSH into a server with no public IP?
&lt;/h2&gt;

&lt;p&gt;The production server sits behind a residential NAT with no port forwarding and no static IP. GitHub-hosted runners spin up in Azure — they can't SSH into &lt;code&gt;192.168.x.x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tried two approaches before finding one that worked in automation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare Access SSH: the wrong tool for CI/CD
&lt;/h3&gt;

&lt;p&gt;I first tried Cloudflare Zero Trust with Service Tokens. The idea: create an HTTP tunnel (&lt;code&gt;cloudflared&lt;/code&gt;) on the server, authenticate the runner via Service Token, and proxy SSH over HTTPS.&lt;/p&gt;

&lt;p&gt;It worked in manual testing. It failed in CI/CD. Multiple workflow runs produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: username is empty
error: websocket: bad handshake
error: in libcrypto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause: Cloudflare's Service Token authentication doesn't reliably forward the SSH username from the runner to the server. Behavior varied between &lt;code&gt;cloudflared&lt;/code&gt; versions — a &lt;a href="https://github.com/cloudflare/cloudflared/issues/1673" rel="noopener noreferrer"&gt;known bug&lt;/a&gt; in v2026.6.0 made it worse. After two days of debugging, I scrapped the approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tailscale WireGuard: the solution that actually works
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://tailscale.com" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; creates a WireGuard mesh network. The key insight: both the server and the runner become nodes in the same encrypted mesh, regardless of NAT.&lt;/p&gt;

&lt;p&gt;The setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Server&lt;/strong&gt;: &lt;code&gt;curl -fsSL https://tailscale.com/install.sh | sh &amp;amp;&amp;amp; tailscale up --ssh&lt;/code&gt; — joins the mesh with a stable IP (&lt;code&gt;100.x.x.x&lt;/code&gt;) and enables Tailscale SSH (OIDC-based auth, no SSH keys needed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runner&lt;/strong&gt;: the &lt;a href="https://github.com/tailscale/github-action" rel="noopener noreferrer"&gt;&lt;code&gt;tailscale/github-action&lt;/code&gt;&lt;/a&gt; creates an &lt;strong&gt;ephemeral node&lt;/strong&gt; in the mesh using an auth key. The node is destroyed when the job ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection&lt;/strong&gt;: &lt;code&gt;ssh deploy-user@100.x.x.x&lt;/code&gt; — direct, encrypted, no intermediate proxy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why Tailscale won over Cloudflare for CI/CD:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Cloudflare Access SSH&lt;/th&gt;
&lt;th&gt;Tailscale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth mechanism&lt;/td&gt;
&lt;td&gt;Service Token (HTTP headers)&lt;/td&gt;
&lt;td&gt;OIDC via auth key (WireGuard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliability in automation&lt;/td&gt;
&lt;td&gt;Intermittent failures&lt;/td&gt;
&lt;td&gt;Consistent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary required on runner&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cloudflared&lt;/code&gt; (download each job)&lt;/td&gt;
&lt;td&gt;None (GitHub Action handles it)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;HTTPS (HTTP tunnel)&lt;/td&gt;
&lt;td&gt;WireGuard (UDP, end-to-end encrypted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free (Zero Trust Free tier)&lt;/td&gt;
&lt;td&gt;Free (Personal plan, 1,000 ephemeral min/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  7 layers of defense: from SHA pins to rollback automation
&lt;/h2&gt;

&lt;p&gt;Moving to hosted runners eliminated the Docker socket and persistence risks. But a hardened pipeline needs more. Here are the 7 layers I implemented, each one addressing a specific attack vector.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: SHA-pinned actions
&lt;/h3&gt;

&lt;p&gt;After the &lt;a href="https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-attack" rel="noopener noreferrer"&gt;tj-actions/changed-files supply chain attack&lt;/a&gt; (March 2025), I pinned every action by its &lt;strong&gt;full commit SHA&lt;/strong&gt; — not by mutable tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Vulnerable to tag hijack&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

&lt;span class="c1"&gt;# Pinned to immutable commit SHA&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11&lt;/span&gt; &lt;span class="c1"&gt;# v4.1.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I maintain a verification script that audits all SHAs against the GitHub API before each release. Dependabot PRs for &lt;code&gt;github-actions&lt;/code&gt; ecosystem keep them updated weekly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: minimal permissions
&lt;/h3&gt;

&lt;p&gt;Every workflow starts with the tightest possible scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;  &lt;span class="c1"&gt;# workflow-level default&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;  &lt;span class="c1"&gt;# only this job pushes to GHCR&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;   &lt;span class="c1"&gt;# only pulls from GHCR&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;id-token: write&lt;/code&gt;, no &lt;code&gt;pages: write&lt;/code&gt;, no broad &lt;code&gt;write-all&lt;/code&gt;. The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is scoped per-job and discarded when the ephemeral VM is destroyed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: script injection prevention
&lt;/h3&gt;

&lt;p&gt;GitHub Actions expressions like &lt;code&gt;${{ github.event.inputs.ref }}&lt;/code&gt; are string-interpolated &lt;em&gt;before&lt;/em&gt; the shell runs. A malicious input can inject arbitrary commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Vulnerable: user-controlled value directly in shell&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Deploying ${{ github.event.inputs.ref }}"&lt;/span&gt;

&lt;span class="c1"&gt;# Safe: pass through env var (shell-quoted by the runner)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;INPUT_REF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.inputs.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Deploying ${INPUT_REF}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I audited every &lt;code&gt;run:&lt;/code&gt; block and moved all user-controllable expressions (&lt;code&gt;github.event.inputs.*&lt;/code&gt;, &lt;code&gt;github.ref_name&lt;/code&gt;, &lt;code&gt;github.actor&lt;/code&gt;) to &lt;code&gt;env:&lt;/code&gt; blocks. Three steps in the deploy workflow were fixed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4: Harden-Runner in audit-then-block mode
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/step-security/harden-runner" rel="noopener noreferrer"&gt;&lt;code&gt;step-security/harden-runner&lt;/code&gt;&lt;/a&gt; runs as the &lt;strong&gt;first step&lt;/strong&gt; in every job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Harden Runner&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863&lt;/span&gt; &lt;span class="c1"&gt;# v2.12.1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;egress-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Start with &lt;code&gt;audit&lt;/code&gt; mode.&lt;/strong&gt; Harden-Runner monitors all network connections, file access, and process execution without blocking anything. Let it run for 2-4 weeks across real deployments to build a baseline of legitimate endpoints (GHCR, Maven Central, Tailscale coordination servers, etc.).&lt;/p&gt;

&lt;p&gt;Once the baseline is stable, review the StepSecurity dashboard and migrate to &lt;code&gt;block&lt;/code&gt; mode with an explicit allowlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Harden Runner&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863&lt;/span&gt; &lt;span class="c1"&gt;# v2.12.1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;egress-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block&lt;/span&gt;
    &lt;span class="na"&gt;allowed-endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;ghcr.io:443&lt;/span&gt;
      &lt;span class="s"&gt;github.com:443&lt;/span&gt;
      &lt;span class="s"&gt;plugins.gradle.org:443&lt;/span&gt;
      &lt;span class="s"&gt;repo.maven.apache.org:443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This two-phase approach avoids false positives breaking deploys on day one while giving you a concrete path to enforcement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layers 5-7: deploy scripts, GHCR auth, and rollback
&lt;/h3&gt;

&lt;p&gt;The remaining three layers handle the deploy mechanics:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 5 — External deploy scripts.&lt;/strong&gt; Deploy logic was extracted from inline YAML heredocs into versioned shell scripts (&lt;code&gt;.github/scripts/&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.github/scripts/api/
├── deploy.sh      # pull, retag, restart, healthcheck
├── rollback.sh    # restore :backup image
├── verify.sh      # pgbackrest + container status
└── cleanup.sh     # docker image prune
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scripts are copied to the server via &lt;code&gt;scp&lt;/code&gt; and executed via &lt;code&gt;ssh&lt;/code&gt;. This eliminates fragile YAML heredocs, complex quoting, and &lt;code&gt;sed -i&lt;/code&gt; strip hacks. Each script has a &lt;code&gt;trap&lt;/code&gt; for cleanup on exit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 6 — GHCR auth without exposing tokens.&lt;/strong&gt; Instead of exporting &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; as an environment variable over SSH (which would appear in process lists), we use Docker's &lt;code&gt;config.json&lt;/code&gt;: the runner creates &lt;code&gt;~/.docker/config.json&lt;/code&gt; with valid GHCR credentials, &lt;code&gt;scp&lt;/code&gt; copies it to the server, and &lt;code&gt;deploy.sh&lt;/code&gt; runs &lt;code&gt;docker --config /path/to/config pull ghcr.io/...&lt;/code&gt; — the token is read from file, never in an env var on the remote host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 7 — Concurrency, timeouts, and rollback.&lt;/strong&gt; Every job has a &lt;code&gt;timeout-minutes&lt;/code&gt; (build: 15, deploy: 25, verify: 15, cleanup: 5). The deploy script creates a &lt;code&gt;:backup&lt;/code&gt; tag of the current image &lt;em&gt;before&lt;/em&gt; pulling the new one. If the health check fails (30 attempts × 5s = 150s), the rollback step restores the backup automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-api-prod&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# never cancel a running production deploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The final architecture: 4 jobs, each on its own ephemeral VM
&lt;/h2&gt;

&lt;p&gt;The production deploy workflow (&lt;code&gt;deploy-api-prod.yml&lt;/code&gt;) orchestrates 4 jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build &amp;amp; Push Image&lt;/strong&gt; — &lt;code&gt;ubuntu-24.04&lt;/code&gt;, 15min timeout, Gradle build → Buildx → GHCR, &lt;code&gt;packages: write&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production Gate&lt;/strong&gt; — Manual approval required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy to Production&lt;/strong&gt; — &lt;code&gt;ubuntu-24.04&lt;/code&gt;, 25min timeout, Tailscale → &lt;code&gt;scp&lt;/code&gt; scripts → &lt;code&gt;ssh deploy.sh&lt;/code&gt;, &lt;code&gt;packages: read&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify Deployment&lt;/strong&gt; — &lt;code&gt;ubuntu-24.04&lt;/code&gt;, 15min timeout, pgbackrest check + container status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup&lt;/strong&gt; — &lt;code&gt;ubuntu-24.04&lt;/code&gt;, 5min timeout, &lt;code&gt;docker image prune&lt;/code&gt;, &lt;code&gt;if: always()&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Key design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image tagging&lt;/strong&gt;: every push creates 3 tags in GHCR — &lt;code&gt;&amp;lt;git-tag&amp;gt;&lt;/code&gt;, &lt;code&gt;sha-&amp;lt;full-commit&amp;gt;&lt;/code&gt; (immutable), and &lt;code&gt;latest&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull by SHA&lt;/strong&gt;: the deploy script pulls by &lt;code&gt;sha-&amp;lt;commit&amp;gt;&lt;/code&gt; (immutable) and retags to the local compose image name. The &lt;code&gt;docker-compose.yml&lt;/code&gt; is never modified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale in every job&lt;/strong&gt;: each job (deploy, verify, cleanup) sets up its own ephemeral Tailscale node. Jobs run on separate VMs — there's no shared state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the core of the deploy job — the part that connects to the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Tailscale&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tailscale/github-action@306e68a...&lt;/span&gt;  &lt;span class="c1"&gt;# v4.1.2&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;authkey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.TS_AUTH_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tag:ci&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Login to GHCR&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@0d4c9c5...&lt;/span&gt;  &lt;span class="c1"&gt;# v3.2.0&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy via SSH&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/api/deploy-via-ssh-api&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ssh-target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.SSH_TARGET }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The composite action (&lt;code&gt;deploy-via-ssh-api&lt;/code&gt;) handles &lt;code&gt;scp&lt;/code&gt; of secrets, scripts, and Docker config — keeping the workflow YAML clean and the logic testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after: the numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Attack surface&lt;/td&gt;
&lt;td&gt;Docker socket + home LAN + persistent runner&lt;/td&gt;
&lt;td&gt;SSH over WireGuard to ephemeral VMs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets on disk during deploy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; + JWT keys persisted indefinitely&lt;/td&gt;
&lt;td&gt;Injected via SSH, cleaned up by &lt;code&gt;trap&lt;/code&gt; on exit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD cost&lt;/td&gt;
&lt;td&gt;$0 (approaching usage-based pricing)&lt;/td&gt;
&lt;td&gt;$0 (public repo, within free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy time (cold start)&lt;/td&gt;
&lt;td&gt;~8-12 min&lt;/td&gt;
&lt;td&gt;~6m 38s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy time (cached)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;~3m 29s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supply chain protection&lt;/td&gt;
&lt;td&gt;Tags (mutable)&lt;/td&gt;
&lt;td&gt;SHA pins (immutable) + Dependabot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual steps to deploy&lt;/td&gt;
&lt;td&gt;Push to branch → auto-deploy&lt;/td&gt;
&lt;td&gt;Push tag → auto-build → manual approval → deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The deploy cold start dropped from 8-12 minutes to 6m 38s. Cached deploys hit 3m 29s. The CI/CD cost stayed at $0 — the public repo is within GitHub Actions' free tier, and Tailscale's Personal plan covers the ephemeral minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Access SSH is not reliable for CI/CD automation.&lt;/strong&gt; It works perfectly for manual access, but Service Token authentication behaves inconsistently across &lt;code&gt;cloudflared&lt;/code&gt; versions. If your server is behind NAT and you need automated SSH, Tailscale (or any WireGuard mesh) is a more reliable choice. I wasted two days debugging Cloudflare before switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt; is acceptable inside a WireGuard mesh.&lt;/strong&gt; I initially spent time trying to dynamically extract host keys from Tailscale. I ultimately decided it wasn't worth the complexity: WireGuard already provides end-to-end encryption and node identity verification. The risk acceptance was documented internally — WireGuard's encryption model made SSH host key verification redundant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract deploy logic from YAML early.&lt;/strong&gt; My first iteration had 50-line heredocs inside &lt;code&gt;run:&lt;/code&gt; blocks with complex quoting, &lt;code&gt;sed -i&lt;/code&gt; to strip indentation, and nested command substitutions. Moving to external &lt;code&gt;.sh&lt;/code&gt; scripts eliminated an entire class of bugs and made the deploy logic testable in isolation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GHCR auth via &lt;code&gt;config.json&lt;/code&gt; beats environment variables.&lt;/strong&gt; Exporting &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; over SSH puts it in the remote process list (&lt;code&gt;/proc/*/environ&lt;/code&gt;). Copying Docker's &lt;code&gt;config.json&lt;/code&gt; via &lt;code&gt;scp&lt;/code&gt; and using &lt;code&gt;docker --config&lt;/code&gt; keeps the token out of the process tree entirely.&lt;/p&gt;

&lt;p&gt;The full write-up went through 34 revision cycles. Most of those were me discovering that the "simple" version of each layer had edge cases I hadn't considered — like the script injection fix requiring an audit of &lt;em&gt;every&lt;/em&gt; &lt;code&gt;run:&lt;/code&gt; block, not just the ones I thought were user-controlled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The workflows and deploy scripts are available in the public repository: &lt;a href="https://github.com/unspoken-tech-org/workshop_rest_api" rel="noopener noreferrer"&gt;unspoken-tech-org/workshop_rest_api&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The runner migration was the foundation. The real hardening came from the layers on top: SHA-pinned actions, minimal permissions, script injection prevention, external deploy scripts, and secure token handling. Each layer is simple individually; together, they create meaningful defense-in-depth — even (or especially) for a solo developer maintaining a public repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you hit the self-hosted runner problem on a public repo?&lt;/strong&gt; I'm curious whether anyone has found a cleaner way to handle the NAT traversal — or if Tailscale's ephemeral node pattern is as good as it gets for this use case.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>githubactions</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
