Your deploy step hangs on an internal hostname while the same command answers instantly from your laptop, and the client build on both machines is identical. The job is running on a GitHub-hosted runner: a virtual machine created for that one job inside GitHub's cloud, with no route into your private address space, no resolver entry for the internal zone, and no certificate the private endpoint will accept. Only two designs fit that constraint, and neither can be switched on later without rebuilding the workflow. Choose between them before the first line of YAML, because migrating after the fact costs more than the first attempt.

The choice is between a runner placed where the network already ends and a tunnel that exists for the length of one job. Both have carried production deploys for years, and they fail in different ways.

Where the boundary actually sits

Anything you install on your laptop stops at your laptop, and anything you install during a job disappears with the runner. There is no persistent network namespace for a client to stay attached to between runs, so each run begins from the same unreachable state. Azure documents this boundary for Microsoft-hosted agents, where VPN and ExpressRoute links into private networks are not supported, and GitHub-hosted runners carry that shape of restriction rather than a separate mechanism. Read it as a product property, not a defect to route around.

No repository setting relaxes it. The job label decides which pool picks the work up, and a hosted label means a machine outside your perimeter for the whole run.

Private reachability therefore has to come from something you own. A runner placed where the network already ends, or a tunnel that lives for one job. The rest of this article is about paying for one of those deliberately instead of discovering the bill in production.

Decide by run frequency, not by familiarity

Reach for a self-hosted runner inside the VPC or office network when deploys land several times a day, when the job touches more than one private port, or when a platform team already owns patching and credential rotation. A job on that machine resolves and routes like any other host behind the firewall. The network work happens once, at install time, instead of on every run.

Build the tunnel inside the job when access is occasional and you do not want a long-lived agent holding a credential into your network. The job becomes its own network client for a few minutes and leaves nothing behind, which is often the whole point.

Estimate the tunnel's cost before you choose it. Every run pays for profile materialisation, client start, readiness, and teardown. A tunnel that comes up but is never verified produces a deploy that fails halfway through and leaves private state in a shape nobody planned. The retry after that is rarely clean, and the second failure usually gets blamed on the client version.

Writing the profile so the step cannot abort

Keep profiles and credentials out of the repository and materialise them from secrets in the step that needs them. The ordering decides whether the job survives: a file created under sudo belongs to root, and the unprivileged step shell cannot then write into it.

- name: Bring up OpenVPN
  run: |
    umask 077
    profile="$RUNNER_TEMP/client.ovpn"
    printf '%s\n' "$VPN_PROFILE" > "$profile"
    : > "$RUNNER_TEMP/openvpn.log"
    printf '%s\n' "$VPN_CREDENTIALS" > "$RUNNER_TEMP/credentials.txt"
    sudo openvpn --daemon openvpn-job \
      --config "$profile" \
      --auth-user-pass "$RUNNER_TEMP/credentials.txt" \
      --writepid "$RUNNER_TEMP/openvpn.pid" \
      --log "$RUNNER_TEMP/openvpn.log"

Both details in that block exist for later steps. With umask 077 the profile lands at mode 0600 owned by the runner user, sudo can still read it, and the client writes its log into a file the runner already owns, so the readiness loop can read that log without a second sudo. --writepid records the PID that teardown will target.

A profile that authenticates with a client certificate needs its key written the same way, and a static-challenge profile needs the matching auth mode. Profiles that push DNS options need openvpn-systemd-resolved on Ubuntu and Debian, because the push alone changes nothing on a base image.

Readiness is a probe, not a pause

A fixed sleep holds right up until the concentrator is slow, and then the deploy step runs before the route exists. The failure then surfaces inside the deploy tool as a timeout against the private host, which says nothing about the tunnel.

- name: Await private endpoint
  run: |
    for attempt in $(seq 1 30); do
      if getent hosts private.example.internal > /dev/null && \
         timeout 3 bash -c 'exec 3<>/dev/tcp/private.example.internal/8443' 2>/dev/null; then
        echo "endpoint answered after $attempt attempts"
        exit 0
      fi
      sleep 2
    done
    grep -Ei 'error|verify|auth' "$RUNNER_TEMP/openvpn.log" | tail -40
    exit 1

The loop watches two observations because either one can be the broken half. getent hosts proves the pushed resolver answers for that name, and the socket open proves something is listening on the port the deploy will use; a route-only profile needs the literal address and the getent line deleted.

When the loop expires, the log is the only thing that separates three different faults: a rejected certificate, a wrong auth mode, and a route that never arrived. Filtering before the tail keeps that output short enough to read while the job is still failing. Read the first error rather than the last line, because a client that cannot authenticate repeats itself until the loop gives up. ip -4 route show dev tun0 answers the simpler question of whether a route was pushed at all, and it costs one line inside the same loop.

Teardown, and the artifact question

Close the job with a step guarded by if: always() that runs sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" and nothing else. On a shared self-hosted runner, never kill by process name: another job may own a client with the same binary and similar arguments. The failure then lands on whichever job ran second, and its log points at a workflow that did nothing wrong.

The guard matters because a failed deploy still leaves a live tunnel. Without always() the cleanup step is skipped exactly when it is needed, and the next run starts against a client that is already connected.

One question remains before you adopt either design: does the build stage need a private route at all? A deployment agent that already sits inside the network can pull an approved artifact, which keeps ordinary builds away from the private path. Fewer jobs holding a tunnel means fewer places for a stale route to hide.

פֿאַרבונדענע אַרטיקלען