Using Pangolin for CGNAT Homelab Access on a NixOS Public Edge Host
22 May 2026
The A9 edge-host guide leaves you with a small NixOS host on OCI whose job is simple: take the public side somewhere other than the home router. The next question is what to put on that host without turning it into a second little platform or teaching the home WAN new bad habits.
This guide uses that same NixOS edge host as the Pangolin control point. The target shape is narrow:
- Pangolin on the public NixOS edge host from A9
- one outbound
Newtsite on the home side - one authenticated public web app
- one private SSH-style admin resource
Pangolin is useful here because it can front browser-based public resources and client-only private resources on the same platform. That matters behind CGNAT, where “just forward the port” is either impossible or a port-forward that causes problems the moment it works.
This is a follow-on to A9, not a general remote-access survey.
The Access Pattern
The worked setup is deliberately small:
| Piece | Placeholder example | Why it exists |
|---|---|---|
| Public edge host | edge-host + pangolin.example.com | The NixOS OCI host from A9 now runs Pangolin and keeps the public side off the home router. |
| Home-side site | home-site | Runs Newt behind the firewall and reaches the actual internal targets. |
| Public web resource | bookmarks.example.com -> http://192.0.2.40:9090 | Browser-facing app path with Pangolin in front. |
| Private admin resource | infra-ssh -> 192.0.2.10:22 | Client-only admin access without opening the home WAN. |
| Admin laptop | Pangolin client | Connects to the private resource when you actually need it. |
That split is the whole point. Browser-facing app access and private admin access are different jobs. Treating them as one job is how people end up with either too much surface or too many workarounds.
Prerequisites And Boundaries
For this pattern you need:
- the
NixOSpublic edge host from A9 or an equivalent small publicNixOShost - a domain you control
- one home-side box that can already reach the internal targets
- one web app you want browser-accessible
- one private admin target that should stay client-only
Pangolin’s current quick-install docs recommend Ubuntu 20.04+ or Debian 11+.
On the A9 host, the cleaner move is to keep Pangolin’s upstream three-part stack shape but let NixOS own it through virtualisation.oci-containers with a Podman backend.
This is not a Cloudflare Tunnel versus Pangolin comparison, an OIDC deep-dive, or a whole-network publication model.
If all you need is your own private SSH path and nothing browser-facing, Pangolin is more stack than the job deserves.
All domains, addresses, IDs, and secrets below are placeholders. Replace them with real values you control.
1. Prepare The A9 Edge Host For Pangolin
Keep the A9 host.
Do not spin up a second public coordinator just because the product docs happen to like Ubuntu.
The public-side prerequisites remain small:
- one public IP on the A9 host
- one delegated domain
- Oracle-side rules and host-side firewall openings for:
80/tcpand443/tcp51820/udp(WireGuard traffic for Pangolin’s tunnel layer) and21820/udp(Gerbil, Pangolin’s relay path)
Pangolin’s stack has three containers that split the work.
pangolin holds the dashboard, sites, resources, and access policy.
gerbil carries the tunnel traffic and the host-side port mappings.
traefik terminates TLS and routes incoming requests.
On this host, traefik shares gerbil’s network namespace so the public ports only need to be published once.
The host-side shape worth aiming for is straightforward:
{ pkgs, ... }:
{
# Trimmed to the parts that define the Pangolin runtime shape.
networking.firewall.allowedTCPPorts = [ 80 443 ];
networking.firewall.allowedUDPPorts = [ 51820 21820 ];
systemd.tmpfiles.rules = [
"d /srv/pangolin/config 0750 root root -"
"d /srv/pangolin/config/traefik 0750 root root -"
"d /srv/pangolin/config/traefik/logs 0750 root root -"
"d /srv/pangolin/config/letsencrypt 0750 root root -"
"d /srv/pangolin/config/db 0750 root root -"
];
systemd.services.create-pangolin-network = {
description = "Create pangolin podman network";
wantedBy = [ "multi-user.target" ];
before = [
"podman-pangolin.service"
"podman-gerbil.service"
"podman-traefik.service"
];
after = [ "network.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
${pkgs.podman}/bin/podman network exists pangolin || \
${pkgs.podman}/bin/podman network create pangolin
'';
};
virtualisation.oci-containers.backend = "podman";
virtualisation.oci-containers.containers.pangolin = {
image = "docker.io/fosrl/pangolin:<pin>";
volumes = [ "/srv/pangolin/config:/app/config" ];
extraOptions = [ "--network=pangolin" ];
};
virtualisation.oci-containers.containers.gerbil = {
image = "docker.io/fosrl/gerbil:<pin>";
dependsOn = [ "pangolin" ];
volumes = [ "/srv/pangolin/config:/var/config" ];
ports = [
"80:80"
"443:443"
"51820:51820/udp"
"21820:21820/udp"
];
cmd = [
"--reachableAt=http://gerbil:3004"
"--generateAndSaveKeyTo=/var/config/key"
"--remoteConfig=http://pangolin:3001/api/v1/"
];
extraOptions = [
"--network=pangolin"
"--cap-add=NET_ADMIN"
"--cap-add=SYS_MODULE"
];
};
virtualisation.oci-containers.containers.traefik = {
image = "docker.io/traefik:<pin>";
dependsOn = [ "gerbil" ];
cmd = [ "--configFile=/etc/traefik/traefik_config.yml" ];
volumes = [
"/srv/pangolin/config/traefik:/etc/traefik:ro"
"/srv/pangolin/config/letsencrypt:/letsencrypt"
"/srv/pangolin/config/traefik/logs:/var/log/traefik"
];
extraOptions = [ "--network=container:gerbil" ];
};
}
That is the real operational split:
NixOSowns the host, the units, and the state root- Pangolin still keeps its upstream
pangolin+gerbil+traefikroles Gerbilcarries the host port mappingsTraefiksharesGerbil’s network namespace instead of publishing a second overlapping set of public ports
The <pin> placeholders are deliberate.
Pin the public-edge images explicitly instead of floating latest on the box that owns your public boundary.
In this pattern, a second oneshot service renders the static traefik_config.yml and dynamic_config.yml files into /srv/pangolin/config/traefik/ before the containers start.
Keep the real domains, ACME email, and key material in private config, not in the article.
If the OCI-side network policy still only allows SSH because you stopped exactly where A9 stopped, fix that first. This article adds a public application boundary. It does not bypass the A9 control split.
2. Keep Pangolin’s Upstream Roles Under One State Root
The host configuration above defines the runtime shape.
The remaining job is the state layout underneath it.
Pangolin’s upstream docs are still Docker-led.
What matters on NixOS is not reproducing the installer but preserving the stack’s real roles under one predictable state root.
The working directory should look like this:
/srv/pangolin/
config/
config.yml
db/
key
letsencrypt/
acme.json
traefik/
dynamic_config.yml
logs/
traefik_config.yml
The file contents for config.yml, traefik_config.yml, and dynamic_config.yml still come from Pangolin’s manual Docker Compose docs.
Use that page for the base templates, then adapt the domain, ACME email, and endpoint values to match your edge host.
What changes on NixOS is who writes those files and who starts the stack around them:
- a oneshot
pangolin-configservice writes the statictraefikfiles into/srv/pangolin/config/traefik/ create-pangolin-networkmakes the shared Podman network oncevirtualisation.oci-containersdefines the three long-running services
One state root keeps the public boundary legible: one backup target, one place to check when something breaks.
3. Start Pangolin On The NixOS Edge Host
Do not drop back into an imperative Compose workflow here.
Apply the host configuration using your normal NixOS deployment path, then validate the generated units:
sudo systemctl status create-pangolin-network.service --no-pager
sudo systemctl status podman-pangolin.service podman-gerbil.service podman-traefik.service --no-pager
sudo podman ps
sudo podman logs --tail=50 pangolin
sudo podman logs --tail=50 gerbil
sudo podman logs --tail=50 traefik
sudo ss -ltnup | rg ':(80|443|51820|21820)\\b'
What counts as healthy on the edge host:
pangolin,gerbil, andtraefikall showrunning- the host is listening on
80,443,51820/udp, and21820/udp - the dashboard URL resolves to the edge host and loads the initial setup flow
Then complete the initial Pangolin setup at the dashboard URL you defined, typically:
https://pangolin.example.com/auth/initial-setup
After the initial setup you should have:
- one admin account on the Pangolin dashboard
- the base domain and TLS working
- zero sites and zero resources configured
If the dashboard does not load or TLS is not working, fix it here. Every section below depends on this step.
4. Create One Home-Side Site With Newt
Pangolin uses sites as its label for remote networks that connect back to the central server.
Newt is the binary that holds the outbound tunnel open.
For this pattern, the home-side machine should:
- live behind the home firewall
- already be able to reach the internal targets you care about
- make outbound connections to Pangolin
Create a site in the Pangolin dashboard first and copy the generated credentials.
That site gives you the NEWT_ID and NEWT_SECRET values the home-side connector needs.
NixOS-First Home-Side Path
If the home-side box is also NixOS, keep Newt in the same Podman-managed shape as the public edge instead of inventing a one-off binary install.
{
virtualisation.oci-containers.backend = "podman";
systemd.tmpfiles.rules = [
"d /etc/newt 0750 root root -"
];
virtualisation.oci-containers.containers.newt = {
image = "docker.io/fosrl/newt:<pin>";
environmentFiles = [ "/etc/newt/newt.env" ];
};
systemd.services.podman-newt = {
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
};
}
The root-owned env file stays small:
NEWT_ID=replace-me
NEWT_SECRET=replace-me
PANGOLIN_ENDPOINT=https://pangolin.example.com
Create /etc/newt/newt.env before you deploy.
If you already use the A7 agenix pattern, point environmentFiles at the rendered secret path instead and keep the store clean.
Apply the host configuration using your normal NixOS deployment path, then validate:
sudo systemctl status podman-newt --no-pager
sudo podman logs --tail=50 newt
Debian/Ubuntu Fallback
If the home-side connector box is not NixOS, keep the same credentials file and the same service shape, just without pretending the machine is in your flake repo.
[Unit]
Description=Newt
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/newt/newt.env
ExecStart=/usr/local/bin/newt
Restart=always
RestartSec=2
UMask=0077
[Install]
WantedBy=multi-user.target
On Debian or Ubuntu, the full working order is:
curl -fsSL https://static.pangolin.net/get-newt.sh | bash
sudo install -d -m 0755 /etc/newt
sudo editor /etc/newt/newt.env
sudo chmod 600 /etc/newt/newt.env
sudo systemctl daemon-reload
sudo systemctl enable --now newt
sudo systemctl status newt --no-pager
The fallback is there because mixed estates are normal.
The boundary is the same on both paths:
Newtstays behind the firewall- it uses outbound connectivity only
- the home router does not get a fresh inbound port-forwarding rule
If you start opening inbound home ports here, stop. The home side is still outbound-only.
5. Add One Authenticated Public Web Resource
Pangolin splits resources into two main types:
public resourcesfor browser-facing accessprivate resourcesfor client-only access
Start with one browser-facing app.
Use a public resource like this:
| Field | Placeholder value | Why it looks like this |
|---|---|---|
| Site | home-site | The resource lives behind the home-side connector. |
| Public hostname | bookmarks.example.com | Browser-facing DNS name. |
| Backend target | http://192.0.2.40:9090 | Placeholder internal service target. |
| Auth posture | require Pangolin login | Keep Pangolin in front of the app instead of publishing it naked. External identity-provider work can wait. |
One clean public-web path falls out of this:
- the browser talks to Pangolin on the public edge
- Pangolin enforces the front-door access policy
- the home-side site forwards to the internal app target
Keep the example that small. Validate this boundary before you add every internal service you own.
Do not use Pangolin’s raw public TCP/UDP resource model for things that should stay private.
For SSH-style admin access, the private-resource path is the better fit.
6. Add One Private Admin Resource
Now add the private half of the pattern.
A private resource keeps the target off the ordinary public web path and requires a Pangolin client. That is the right model for SSH, internal databases, and anything else that has no business being a public proxy target.
Use a private resource like this:
| Field | Placeholder value | Why it looks like this |
|---|---|---|
| Site | home-site | The target still lives behind the same connector. |
| Resource label | infra-ssh | Human-readable but generic. |
| Destination | 192.0.2.10:22 | Placeholder SSH target only. |
| Access policy | your admin user or role only | Keep the audience small and deliberate. |
Then install a Pangolin client on the admin laptop and connect it to the same organization.
That client path is what makes the private resource reachable.
Once connected, private resources become reachable by the alias or address you assigned.
If you assign the resource an internal alias such as infra-ssh, test that alias from the client machine before adding anything else.
7. Validate The Boundary Before You Trust It
The dashboard loading is not the success condition. The success condition is that the public and private paths behave differently, on purpose.
If the edge-host services no longer look healthy, rerun the host checks from section 3 first.
Then check the home-side connector:
sudo systemctl status podman-newt --no-pager
sudo podman logs --tail=50 newt
If you used the Debian/Ubuntu fallback, swap those checks for sudo systemctl status newt --no-pager and sudo journalctl -u newt -n 50 --no-pager.
Then validate the actual access model:
- Visiting
https://bookmarks.example.comshould hit Pangolin’s front door before it hits the app. - The backend app should not be reachable from outside the home network; this setup should not require new inbound port-forwarding rules on the home router.
- The private admin target should require the Pangolin client path.
- Without the Pangolin client connected, the private admin target should not gain a surprise public path.
If the public resource works but the private path does not, fix the site, the client, or the resource policy. Do not widen the firewall to make the symptom go away.
Fit Notes And Limits
This pattern is useful when you need:
- the A9
NixOSedge host to become a real access boundary instead of just an SSH foothold - one public control point
- one or a few browser-facing apps
- one or a few private admin targets
- a home network that stays outbound-only
It is also not a substitute for backup, naming, secrets handling, and ordinary operational discipline on the public host. The platform gives you a cleaner access boundary. It does not paper over what is underneath.
What Comes Next
The public edge host itself comes from Setting Up a NixOS Oracle Cloud Instance as a Small Public Edge Host. If it still feels like an unmanaged snowflake, tighten the service habits with Building a Minimal NixOS Homelab Host and Adding a Homelab Service Declaratively with NixOS and Podman. For connector or service secrets in a Nix-managed environment, keep them out of the store with Managing Homelab Secrets with NixOS and agenix.
The public edge stays small, the home WAN stays closed, and each access path has one job.