Setting up my home-server on JioFiber: My CGNAT-to-IPv6 Journey
Saman Pandey
Jul 10, 2026 · 8 min read
I've been slowly turning an old Arch Linux box into my own little VPS for application deployment.
Nothing fancy: a couple of Node/Express services managed with pm2, nginx in front doing SSL termination and reverse proxying, and two subdomains routing to them:
myserver.local.singularitydev.xyz→ Express API on port 8000app.local.singularitydev.xyz→ frontend app on port 9002
Locally, everything checked out. curl -I http://127.0.0.1:8000/health came back clean, nginx.conf looked correct, and my Let's Encrypt wildcard cert for *.local.singularitydev.xyz was sitting right there in /etc/letsencrypt/live/.
Then I pointed a browser at the actual domain.
This site can't provide a secure connection.
myserver.local.singularitydev.xyzuses an unsupported protocol.ERR_SSL_VERSION_OR_CIPHER_MISMATCH
That single error ended up unraveling three separate problems stacked on top of each other. Here's the whole debugging trail, in order.
The Setup
DNS lives on Cloudflare. The relevant records looked like this:
| Name | Type | Target | Proxy status |
|---|---|---|---|
local.singularitydev.xyz |
A | ab.cd.ef.gh |
DNS only |
*.local.singularitydev.xyz |
CNAME | local.singularitydev.xyz |
Proxied |
Everything else on the zone — the root domain, a Vercel frontend, a Render catch-all — wasn't part of this problem.
Problem 1: The Wildcard That Wasn't Really a Wildcard
My first instinct was that nginx's SSL config was broken. It wasn't.
The real issue: *.local.singularitydev.xyz was set to Proxied (orange cloud) in Cloudflare, while local.singularitydev.xyz itself was DNS only. Cloudflare's free Universal SSL certificate only covers the apex domain plus one level of subdomain — *.singularitydev.xyz, not *.local.singularitydev.xyz. Mine was two labels deep.
So the moment a browser hit myserver.local.singularitydev.xyz, it landed on Cloudflare's edge (because the wildcard CNAME was proxied), and Cloudflare had no certificate that matched that hostname. Handshake dies before it ever reaches my server. That's the whole error, right there.
There were three ways out:
- Buy Cloudflare's Advanced Certificate Manager
- Flatten my hostnames to a single level
- Stop routing through Cloudflare's edge entirely and let my own nginx + Let's Encrypt handle TLS directly
I went with option 3 — I'd already done the work of getting a working wildcard cert via a DNS-01 challenge, so there was no reason to pay for something I already had.
Problem 2: JioFiber's CGNAT
Going direct meant clients needed to reach my box's public IP. Except — they couldn't.
My "public" IPv4 (ab.cd.ef.gh) was a CGNAT address. JioFiber home plans don't hand out a real public IPv4 by default; the address my router shows is shared across many customers, and inbound connections to it silently go nowhere. Port forwarding rules save fine in the router UI and do precisely nothing, because the actual NAT translation happens upstream at the ISP, not on my router.
The fix Jio does give you for free: native, public IPv6. Unlike IPv4, there's no address exhaustion problem here, so every JioFiber connection gets a real globally routable IPv6 address — inbound just needs to be explicitly allowed.
Step 1: Verify IPv6 Connectivity
ip -6 addr show scope global
Look for something starting with 2xxx: or 3xxx: — that's a global address. fe80:: is link-local and useless for this.
Step 2: Stabilize the Address
Linux uses privacy extensions by default, which rotate your IPv6 suffix periodically. Not what you want on something acting as a server:
nmcli connection modify "<your-connection>" ipv6.ip6-privacy 0
nmcli connection up "<your-connection>"
Step 3: Open the JioFiber Router's IPv6 Side
This is where it stops looking like normal port forwarding. Jio's IPv6 firewall allowlists a specific destination address, not just a port.
Login → Security → Firewall → IPv6 Firewall Rules → Add new, and add inbound allow rules for HTTPS, HTTP, and DNS-TCP, destination set to my box's IPv6.
There's a better option if your router supports it: Advanced → Network → DMZ IPv6 Host, which felt simpler to set up. I turned this on and pointed it at my current IPv6 address — though (as I found out later) it's still pinned to that specific address, not a MAC, so a prefix rotation can still break it.
Step 4: Local Firewall
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
ufw is dual-stack by default on Arch, so this covers both IPv4 and IPv6 automatically.
Step 5: nginx — Listen on IPv6
Every server block needed a second listen line:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name myserver.local.singularitydev.xyz;
ssl_certificate /etc/letsencrypt/live/local.singularitydev.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/local.singularitydev.xyz/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Miss this line and you'll get connection refused on IPv6 even with everything else correct.
Step 6: The DNS Record I Almost Missed
I added a fresh AAAA record on local.singularitydev.xyz pointing at my new stable IPv6 and set it to DNS only, matching the existing A record. Should've been done at that point.
It wasn't. dig AAAA myserver.local.singularitydev.xyz kept returning Cloudflare's own IP ranges (2606:4700:...), not mine.
Here's the gotcha: myserver.local.singularitydev.xyz doesn't resolve off the AAAA record directly — it resolves off the *.local.singularitydev.xyz CNAME, which was still set to Proxied from the very beginning of this whole setup. Two records, two independent proxy toggles, and I'd only fixed one of them. The wildcard CNAME was still routing everything into Cloudflare's edge — same root cause as Problem 1, just resurfacing in a different spot.
Flipping that CNAME's cloud icon to grey was the actual fix. Obvious in hindsight; cost me a good twenty minutes of confusion in the moment.
Step 7: Keeping the AAAA Record in Sync
JioFiber's IPv6 prefix isn't fixed — it can rotate on router reboots or ISP-side lease changes. I wrote a small script that detects the current global IPv6 and pushes it to Cloudflare via their API if it's changed, run every 5 minutes off a systemd timer:
CURRENT_IP=$(ip -6 addr show dev "$INTERFACE" scope global \
| awk '/inet6/{print $2}' | cut -d/ -f1 | grep -vi '^fd' | head -n1)
curl -s -X PATCH \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
--data "{\"type\":\"AAAA\",\"name\":\"${RECORD_NAME}\",\"content\":\"${CURRENT_IP}\",\"ttl\":60,\"proxied\":false}"
One thing this script doesn't cover: if your router's DMZ or firewall rule is pinned to a fixed IPv6 (mine was), a prefix rotation updates Cloudflare fine but leaves the router still blocking the new address until you manually re-enter it. Worth checking if your router supports MAC-based DMZ instead of address-based — mine only offered the latter.
Step 8: The Debugging Dead End That Wasn't
Even after every fix landed, curl -6 kept resolving to Cloudflare's IPs. Turned out to be nothing more than a stale DNS cache — but tracking that down meant separating "is my config broken" from "is my resolver lying to me." Querying Cloudflare's authoritative nameserver directly cut through it immediately:
dig NS singularitydev.xyz +short
dig AAAA myserver.local.singularitydev.xyz @veronica.ns.cloudflare.com +short
That returned my real IPv6 straight away, confirming the DNS side was already correct — the delay was purely local/router-level caching.
To verify the actual server chain without waiting on DNS at all, I bypassed resolution entirely:
curl -6 -vk https://myserver.local.singularitydev.xyz/health \
--resolve myserver.local.singularitydev.xyz:443:my-ipv6-address
That's the moment everything clicked: full TLS 1.3 handshake, correct wildcard cert served, request proxied through nginx to Express, clean 200 OK.
Once the cache aged out, the same request worked without --resolve or -k at all — full certificate verification passing on its own.
Final Result
curl -6 -v https://myserver.local.singularitydev.xyz/health
* subjectAltName: "myserver.local.singularitydev.xyz" matches cert's "*.local.singularitydev.xyz"
* SSL certificate verified via OpenSSL.
< HTTP/1.1 200 OK
{"status":"ok"}
No public IPv4. No paid Cloudflare tier. No tunnel. Just native IPv6, my own certs, and nginx doing exactly what it was configured to do all along.
Closing Thoughts
Three unrelated-looking problems turned out to share one shape: traffic quietly going somewhere other than where I assumed.
- CGNAT sent my "public" IP nowhere real.
- A forgotten proxy toggle on a CNAME sent my wildcard traffic into Cloudflare's edge instead of my own server.
- A stale cache sent my own debugging tools to the wrong answer.
None of these were nginx's fault, and none of them showed up as an nginx error. If you're chasing a certificate or connection error and your server-side config looks completely correct, the traffic is probably never reaching your server at all — check DNS and routing before you touch anything else.
If you're behind JioFiber CGNAT and want to self-host without paying for a static IP or routing through someone else's tunnel, IPv6 is genuinely enough. It just rewards being paranoid about every hop between the browser and your server, not just the last one.