Shocking!!! GitHub Still Doesn’t Fully Support IPv6 in Late 2026 (and A WARP Solution for an IPv6-Only VPS)

I’ll update this article when GitHub supports IPv6. Idk when…

***

In 2026, an IPv6-only VPS could serve websites perfectly yet still fail at the humble task of pushing a backup to GitHub. The website was healthy; the route was not. This case study explains how I used Cloudflare WARP only for temporary IPv4 egress, deliberately kept native IPv6 and SSH outside the tunnel, and made every connection self-cleaning.

A Small Qualification Before the Shocking Part

The title is intentionally dramatic, but the precise technical claim is narrower: from the IPv6-only VPS tested in August 2026, the GitHub endpoints required for HTTPS Git operations, GitHub CLI authentication and API requests were not usable through the server’s native IPv6 connection.

GitHub is not entirely without IPv6. For example, the official GitHub Pages documentation supports AAAA records for custom domains. However, serving a Pages site and pushing a private repository are different network paths. The latter was the path that failed here.

The practical problem was not “Does anything at GitHub understand IPv6?” It was “Can this IPv6-only server complete the entire authenticated GitHub backup workflow without IPv4?” In this case, the answer was no.

A GitHub Community discussion about the IPv6 roadmap reflects the same distinction, although a community answer should not be treated as a formal product commitment. The safest approach is to test the exact endpoints required by your own workflow.

The Original Problem

The VPS hosted several WordPress installations and had working native IPv6 but no ordinary native IPv4 connectivity. Website traffic was healthy, WordPress could reach its database, and SSH worked over IPv6. The backup engine could also prepare a complete local snapshot.

The process failed only when it needed to communicate with GitHub:

  • authenticate through the GitHub CLI;
  • query repository visibility through the GitHub API;
  • create or inspect a private repository;
  • push a fixed Git commit over HTTPS;
  • verify the remote branch and recovery files.

DNS was not the underlying problem. The server could learn an IPv4 address for a GitHub endpoint, but knowing an address is not the same as having an IPv4 route to it. DNS can provide directions; it cannot build the missing road. If only networking were that optimistic.

Confirmed Test Environment

Component Tested value
Operating system Debian GNU/Linux 13
VPS resources 1 virtual CPU, approximately 1 GiB RAM
Native public connectivity IPv6
Git 2.47.3
GitHub CLI 2.97.0
Cloudflare WARP client 2026.6.880.0
Preferred WARP protocol MASQUE
WARP mode tunnel_only
IPv6 tunnel policy ::/0 excluded from WARP
Git transport Authenticated HTTPS

The example paths, site names and repository names in this article are anonymized. A representative WordPress root is /var/www/example-site, while the private repository is represented as example-owner/example-private-backup.

Objectives and Safety Constraints

The objective was not simply to make git push</code work. The solution also had to preserve remote administration and leave the server in a predictable state.

  • Use WARP only while IPv4 access is required.
  • Keep all IPv6 traffic outside WARP.
  • Keep the existing IPv6 SSH connection outside WARP.
  • Use traffic-only mode rather than altering DNS behavior.
  • Verify IPv4 uses WARP and IPv6 does not.
  • Prevent WARP from starting automatically after reboot.
  • Require a volatile authorization marker before the daemon can start.
  • Arm an independent watchdog before connecting.
  • Disconnect and stop WARP after success, failure, timeout or signal.
  • Never print registration details, tokens, license identifiers or public IP addresses.
  • Never push unless the destination repository is confirmed private.
  • Never force-push or rewrite backup history.

This was especially important because changing routes on a remote SSH server is a respectable way to convert a networking experiment into an unscheduled trip to the provider’s recovery console.

Why the Obvious Alternatives Were Rejected

Changing DNS

Changing resolvers could alter how names were resolved, but it could not create IPv4 transport. The server already knew where GitHub was; it simply lacked the appropriate road.

Disabling IPv6

This would have removed the server’s working native connectivity and its SSH path without providing IPv4. It would therefore solve the working half of the network while leaving the broken half broken—a remarkably efficient regression.

Sending All Traffic Through a Permanent VPN

A permanently active full tunnel would unnecessarily alter IPv6 routing, increase the SSH risk and create another boot dependency. The requirement was temporary GitHub egress, not permanent network relocation.

Cloudflare Tunnel

Cloudflare Tunnel is designed primarily to publish applications through outbound tunnel connections. It is not a general replacement for the outbound IPv4 route needed by an arbitrary git push. Cloudflare WARP was the relevant client-side egress tool.

A NAT64 Gateway

NAT64 could be an excellent infrastructure-level solution if the hosting network provided it. Operating a separate NAT64 gateway solely for a small backup workflow would have added more infrastructure, monitoring and failure modes than this case justified.

Buying a Native IPv4 Address

This remains the simplest long-term solution where the provider offers it at an acceptable price. In this case, WARP provided a controlled on-demand solution without redesigning the server network.

The Final Architecture

Component Responsibility
Native IPv6 Normal server networking and SSH
WARP IPv4 path Temporary GitHub access
tunnel_only Tunnel IP traffic without replacing normal DNS handling
::/0 exclusion Keep every IPv6 destination outside WARP
MASQUE Preferred WARP tunnel protocol
Volatile marker Authorize daemon startup only for an active job
Systemd condition Refuse WARP startup when the marker is absent
Rescue command Disconnect, stop, disable and deauthorize WARP
Watchdog Invoke rescue independently if the job hangs
Shell traps Run cleanup on success, failure or interruption

The key design decision was the ::/0 exclusion. WARP supplied the missing IPv4 route, while all IPv6—including the SSH session—continued to use the server’s native network.

Diagnosing the Network Before Installing Anything

The first step was to distinguish three separate questions:

  1. Does the server have a global IPv6 address?
  2. Can the kernel find a functional IPv6 route?
  3. Can the required GitHub workflow complete over native IPv6?

A focused diagnostic can be performed without displaying public addresses:

set -euo pipefail

echo "=== Global IPv6 availability ==="
ip -6 address show scope global >/dev/null
echo "PASS: at least one global IPv6 address exists"

echo "=== Complete IPv6 routing tables ==="
ip -6 route show table all | grep -q '^default'
echo "PASS: an IPv6 default route exists"

echo "=== Functional IPv6 route lookup ==="
IPV6_TARGET="$(
    getent ahostsv6 www.cloudflare.com |
        awk 'NR == 1 {print $1}'
)"
test -n "$IPV6_TARGET"
ip -6 route get "$IPV6_TARGET" >/dev/null
echo "PASS: functional IPv6 route lookup succeeds"

echo "=== Native IPv6 HTTPS ==="
curl -6 \
    --fail \
    --silent \
    --show-error \
    --max-time 20 \
    https://www.cloudflare.com/cdn-cgi/trace |
    grep -E '^warp='

echo "=== GitHub IPv6 name lookup ==="
getent ahostsv6 github.com || true

echo "=== GitHub HTTPS over IPv6 ==="
timeout 20s curl -6 --head https://github.com/ || true

One early audit looked only at an incomplete routing-table view and incorrectly concluded that the IPv6 default route was absent. The correction was to inspect all IPv6 routing tables and perform a real route lookup. Configuration should be validated by function, not only by one preferred line of text.

Installing and Registering WARP

Cloudflare’s current Linux WARP documentation should be used for package installation because repository setup and signing requirements can change.

After installation, the service should initially be stopped and disabled:

sudo systemctl disable --now warp-svc
sudo rm -f /run/example-warp-allow

The initial consumer registration is a one-time operation. The daemon must be running through the authorization gate before invoking the CLI.

sudo install -m 0600 /dev/null /run/example-warp-allow
sudo systemctl reset-failed warp-svc >/dev/null 2>&amp;1 || true
sudo systemctl start warp-svc

sudo warp-cli \
    --accept-tos \
    --no-ansi \
    --no-paginate \
    registration new

Every noninteractive WARP command used --accept-tos. This detail became unexpectedly important during debugging.

Registration details should not be printed into shared terminal transcripts, application logs or web dashboards. Commands such as registration show can expose identifiers that have no business appearing in a technical blog—or enjoying a small holiday in somebody’s log aggregator.

Configuring IPv4-Only WARP Egress

The client was configured in traffic-only mode, with MASQUE as the preferred protocol:

sudo warp-cli \
    --accept-tos \
    --no-ansi \
    --no-paginate \
    mode tunnel_only

sudo warp-cli \
    --accept-tos \
    --no-ansi \
    --no-paginate \
    tunnel protocol set MASQUE

Cloudflare documents MASQUE as the default tunnel protocol in current clients. Protocol values are case-sensitive, so MASQUE should be written exactly as expected by the installed client.

Next, all IPv6 destinations were added to the split-tunnel exclusion list:

if ! sudo warp-cli \
    --accept-tos \
    --no-ansi \
    --no-paginate \
    tunnel ip list |
    grep -Fq -- '::/0'
then
    sudo warp-cli \
        --accept-tos \
        --no-ansi \
        --no-paginate \
        tunnel ip add-range ::/0
fi

In exclude mode, ::/0 represents the complete IPv6 destination space. IPv4 can therefore use WARP while IPv6 bypasses it. Cloudflare’s split-tunnel documentation explains the distinction between included and excluded traffic.

After confirming the configuration, WARP was disconnected again until an actual GitHub operation required it.

Adding a Volatile Systemd Startup Gate

Disabling a service at boot is useful, but it is not a complete safety mechanism. A second condition was added: warp-svc may start only while a volatile file exists under /run.

sudo install -d \
    -o root \
    -g root \
    -m 0755 \
    /etc/systemd/system/warp-svc.service.d

sudo tee \
    /etc/systemd/system/warp-svc.service.d/10-example-reboot-safety.conf \
    >/dev/null &lt;&lt;'EOF'
[Unit]
ConditionPathExists=/run/example-warp-allow
EOF

sudo systemctl daemon-reload
sudo systemctl disable warp-svc
sudo rm -f /run/example-warp-allow

Because /run is volatile, the marker disappears after reboot. Even if another configuration accidentally tries to start WARP, systemd refuses unless the current operation has explicitly recreated the marker.

Removing the marker does not stop a daemon that is already running. Cleanup must still disconnect and stop the service explicitly.

Creating an Independent Rescue Command

A small root-owned rescue command provided one authoritative way to shut everything down:

sudo tee /usr/local/sbin/example-warp-rescue >/dev/null &lt;&lt;'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail

timeout 15s warp-cli \
    --accept-tos \
    --no-ansi \
    --no-paginate \
    disconnect >/dev/null 2>&amp;1 || true

systemctl stop warp-svc >/dev/null 2>&amp;1 || true
systemctl disable warp-svc >/dev/null 2>&amp;1 || true
rm -f /run/example-warp-allow
EOF

sudo chown root:root /usr/local/sbin/example-warp-rescue
sudo chmod 0750 /usr/local/sbin/example-warp-rescue

This command deliberately does not print registration information. Its purpose is rescue, not autobiography.

Arming a Watchdog Before Connecting

The main backup process had shell traps, but a trap cannot help if the process hangs indefinitely or is terminated unusually. An independent transient systemd timer was therefore armed before WARP connected:

WATCHDOG="example-warp-watchdog-$(date -u +%Y%m%dT%H%M%SZ)"

sudo systemd-run \
    --quiet \
    --unit="$WATCHDOG" \
    --on-active=15m \
    /usr/local/sbin/example-warp-rescue \
    watchdog

If the normal workflow fails to clean up within fifteen minutes, systemd invokes the rescue command independently.

For a long Git upload, the watchdog duration must be extended before pushing. It should never be shorter than the legitimate maximum duration of the operation it supervises.

The Controlled Connection Sequence

The production sequence followed this order:

  1. acquire the backup lock;
  2. confirm no backup is already running;
  3. confirm WARP is inactive and disabled;
  4. confirm the volatile marker is absent;
  5. verify website services and maintenance state;
  6. record the number of established SSH sessions without printing addresses;
  7. create the volatile authorization marker;
  8. start warp-svc;
  9. wait for the WARP IPC interface;
  10. arm the independent watchdog;
  11. connect WARP;
  12. verify IPv4 and IPv6 routing independently;
  13. verify SSH continuity;
  14. perform the GitHub operation;
  15. disconnect, stop, disable and remove the marker;
  16. verify the final safety state.

A simplified wrapper looks like this:

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

MARKER="/run/example-warp-allow"
RESCUE="/usr/local/sbin/example-warp-rescue"
WATCHDOG="example-warp-job-$(date -u +%Y%m%dT%H%M%SZ)"

cleanup() {
    local rc=$?
    trap - EXIT INT TERM HUP
    set +e

    timeout 15s warp-cli \
        --accept-tos \
        --no-ansi \
        --no-paginate \
        disconnect >/dev/null 2>&amp;1

    "$RESCUE" job-cleanup >/dev/null 2>&amp;1

    systemctl stop \
        "${WATCHDOG}.timer" \
        "${WATCHDOG}.service" >/dev/null 2>&amp;1

    systemctl reset-failed \
        "${WATCHDOG}.timer" \
        "${WATCHDOG}.service" >/dev/null 2>&amp;1

    rm -f "$MARKER"
    exit "$rc"
}

trap cleanup EXIT INT TERM HUP

exec 9>/run/lock/example-github-backup.lock
flock -n 9 || {
    echo "Another backup is already running." >&amp;2
    exit 1
}

systemctl is-active --quiet warp-svc &amp;&amp; {
    echo "WARP was already active." >&amp;2
    exit 1
}

systemctl is-enabled --quiet warp-svc 2>/dev/null &amp;&amp; {
    echo "WARP was unexpectedly boot-enabled." >&amp;2
    exit 1
}

test ! -e "$MARKER"

SSH_BEFORE="$(
    ss -Htn state established '( sport = :22 )' |
        wc -l
)"
test "$SSH_BEFORE" -ge 1

install -m 0600 /dev/null "$MARKER"
systemctl reset-failed warp-svc >/dev/null 2>&amp;1 || true
systemctl start warp-svc

READY=0

for attempt in $(seq 1 30); do
    if timeout 5s warp-cli \
        --accept-tos \
        --no-ansi \
        --no-paginate \
        status >/dev/null 2>&amp;1
    then
        READY=1
        break
    fi

    sleep 1
done

test "$READY" -eq 1

systemd-run \
    --quiet \
    --unit="$WATCHDOG" \
    --on-active=15m \
    "$RESCUE" watchdog

warp-cli \
    --accept-tos \
    --no-ansi \
    --no-paginate \
    connect >/dev/null

CONNECTED=0

for attempt in $(seq 1 40); do
    STATUS="$(
        timeout 5s warp-cli \
            --accept-tos \
            --no-ansi \
            --no-paginate \
            status 2>/dev/null || true
    )"

    if grep -qi 'Connected' &lt;&lt;&lt;"$STATUS"; then
        CONNECTED=1
        break
    fi

    sleep 1
done

test "$CONNECTED" -eq 1

curl -4 \
    --fail \
    --silent \
    --max-time 20 \
    https://www.cloudflare.com/cdn-cgi/trace |
    grep -qx 'warp=on'

curl -6 \
    --fail \
    --silent \
    --max-time 20 \
    https://www.cloudflare.com/cdn-cgi/trace |
    grep -qx 'warp=off'

SSH_AFTER="$(
    ss -Htn state established '( sport = :22 )' |
        wc -l
)"
test "$SSH_AFTER" -ge 1

PRIVATE="$(
    gh api \
        repos/example-owner/example-private-backup \
        --jq .private
)"
test "$PRIVATE" = true

git push origin main

The Cloudflare trace endpoint also reports the public address. Piping its output directly to grep -qx 'warp=on' or grep -qx 'warp=off' verifies the required state without printing that address.

Validation Results

The controlled test confirmed all of the following:

PASS: WARP daemon started through the volatile gate
PASS: WARP IPC and noninteractive CLI became ready
PASS: tunnel_only configured
PASS: MASQUE configured
PASS: ::/0 IPv6 exclusion configured
PASS: WARP reported Connected
PASS: IPv4 reported warp=on
PASS: IPv6 reported warp=off
PASS: SSH continuity retained
PASS: authenticated GitHub API access
PASS: private repository verified
PASS: Git push completed
PASS: remote commit verified
PASS: WARP inactive after cleanup
PASS: WARP disabled at boot
PASS: volatile marker absent

The pattern was subsequently used for private WordPress backups from multiple isolated sites. The repositories, databases and histories remained separate, while one global resource lock prevented concurrent jobs on the small VPS.

Errors and Debugging Lessons

The “Missing IPv6 Route” That Wasn’t Missing

An early audit inspected an incomplete routing-table view and reported no default IPv6 route. Native IPv6 HTTPS was nevertheless working. Inspecting all tables and performing a functional route lookup revealed the correct state.

Lesson: route configuration can exist outside the one table or textual form a script expects. Validate actual connectivity as well as configuration output.

The “Broken WARP IPC” That Was Actually a Terms Flag

The WARP daemon was running, and its Unix socket existed, but noninteractive CLI checks failed. The initial diagnosis blamed IPC readiness.

The actual problem was that the commands omitted --accept-tos. Once that option was used consistently, the CLI communicated with the daemon normally. The daemon was not dead; it was waiting for paperwork.

Lesson: every automated warp-cli invocation should use the required noninteractive options consistently.

Querying Registration While the Daemon Was Stopped

Another diagnostic attempted to inspect registration before starting warp-svc. The resulting failure did not prove that registration was missing.

Lesson: establish daemon and IPC readiness before interpreting registration or settings failures.

Treating systemctl reset-failed as Critical

One workflow stopped because systemctl reset-failed returned a nonzero result for an inactive or unloaded unit. Resetting a stale failure state was useful housekeeping, but it was not a safety prerequisite.

systemctl reset-failed warp-svc >/dev/null 2>&amp;1 || true

Lesson: distinguish essential checks from best-effort cleanup.

Overly Exact Settings Validation

One registration-rotation operation completed the sensitive part successfully but then failed because the textual representation of tunnel_only differed from the exact string expected by the validator.

The replacement validation normalized case, spaces, underscores and punctuation before comparing values.

Lesson: where a CLI has no structured output, normalize display text before testing semantic equivalence.

A Registration Identifier Appeared in Diagnostic Output

A verbose diagnostic accidentally exposed a consumer registration identifier. The registration was later deleted and recreated in a separate controlled operation, without printing replacement details.

Lesson: do not collect or display registration show output unless it is genuinely necessary. Network diagnostics should be designed around the exact facts required.

A GitHub Push Returned an Internal Server Error

One backup completed local capture and commit creation, but GitHub rejected the push with an Internal Server Error. Maintenance cleanup, WARP shutdown and temporary-file removal all succeeded.

The engine was then hardened with three bounded push attempts. Each retry:

  • used the same fixed commit;
  • did not force-push;
  • re-established the controlled WARP path if necessary;
  • rechecked repository privacy;
  • waited before trying again.

A later push succeeded and the remote commit was verified. The Internal Server Error was therefore treated as a remote transient failure, not evidence of a broken WARP route or corrupted local snapshot.

Why WARP Was Not Left Running

WARP could technically remain connected, but that would weaken the design:

  • future routing changes could affect SSH;
  • a client update could change default behavior;
  • an unnoticed tunnel could complicate unrelated diagnostics;
  • boot-time dependency would increase;
  • the server needed IPv4 only during GitHub operations.

The normal idle state was therefore explicit:

WARP service: inactive
WARP boot state: disabled
Authorization marker: absent
IPv6 HTTPS: native, warp=off
SSH: connected over native IPv6

“Off unless needed” is easier to reason about than “probably harmless in the background.” Production systems benefit from fewer mysterious roommates.

Security and Operational Limitations

  • WARP is an additional network dependency. If Cloudflare connectivity fails, the GitHub operation cannot proceed.
  • Excluding ::/0 protects native IPv6 routing, but the configuration must be revalidated after WARP client upgrades.
  • The watchdog duration must accommodate legitimate upload time.
  • Provider console access should remain available before testing route changes remotely.
  • A private repository must be rechecked immediately before every push.
  • Trace, registration and diagnostic output may contain identifiers or addresses and should be filtered.
  • WARP registration rotation should be separate from backup, repository or deployment changes.
  • A native IPv4 address or provider-managed NAT64 service may still be simpler for a permanent high-volume workload.
  • GitHub’s networking can change. The original IPv6 limitation should be periodically retested rather than preserved as eternal doctrine.

Possible Future Improvements

Automatic Scheduled Backups

The controlled WARP wrapper can be called by a systemd timer. On a small VPS, jobs should remain sequential, off-peak and protected by one global lock.

Failure Notifications

A notification mechanism could report the site identifier, stage and final safety state. It should not include database credentials, tokens, raw registration output or unrestricted root logs.

Native IPv6 Retesting

A periodic non-mutating test could check whether the required GitHub web, API and Git transport endpoints have gained usable IPv6 support. If the complete workflow eventually works natively, WARP can be retired from this job—and this article can receive the update it has been waiting for.

Restoration Testing

A successful push proves that the snapshot reached a private repository. A full recovery rehearsal should still be performed on a disposable VPS with enough disk space, never by importing the backup over the production database.

Practical Checklist

  1. Confirm native IPv6 works before changing routes.
  2. Confirm an established SSH session exists.
  3. Keep ::/0 outside WARP.
  4. Use tunnel_only when DNS tunnelling is unnecessary.
  5. Use --accept-tos on every automated WARP command.
  6. Require a volatile marker before starting the daemon.
  7. Disable WARP at boot.
  8. Arm an independent watchdog before connecting.
  9. Verify IPv4 with warp=on.
  10. Verify IPv6 with warp=off.
  11. Verify SSH continuity before contacting GitHub.
  12. Verify repository privacy immediately before pushing.
  13. Retry only bounded, idempotent pushes—never force-push.
  14. Disconnect, stop, disable and remove the marker in every exit path.
  15. Verify the final network and maintenance state before claiming success.

Conclusion

The final solution did not try to make the whole IPv6-only VPS pretend to be an IPv4 server. It introduced the smallest missing capability: temporary IPv4 egress for GitHub.

Cloudflare WARP handled IPv4, native IPv6 continued carrying SSH, a split-tunnel exclusion kept the two paths separate, and systemd gates, watchdogs and traps ensured that the tunnel disappeared after use.

It was more engineering than one might expect for the command git push. But until the complete GitHub workflow works natively from an IPv6-only server, borrowing IPv4 carefully is better than borrowing trouble permanently.