Using Pangolin for CGNAT Homelab Access on a NixOS Public Edge Host

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 Newt site 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:

PiecePlaceholder exampleWhy it exists
Public edge hostedge-host + pangolin.example.comThe NixOS OCI host from A9 now runs Pangolin and keeps the public side off the home router.
Home-side sitehome-siteRuns Newt behind the firewall and reaches the actual internal targets.
Public web resourcebookmarks.example.com -> http://192.0.2.40:9090Browser-facing app path with Pangolin in front.
Private admin resourceinfra-ssh -> 192.0.2.10:22Client-only admin access without opening the home WAN.
Admin laptopPangolin clientConnects 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 NixOS public edge host from A9 or an equivalent small public NixOS host
  • 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/tcp and 443/tcp
    • 51820/udp (WireGuard traffic for Pangolin’s tunnel layer) and 21820/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:

  • NixOS owns the host, the units, and the state root
  • Pangolin still keeps its upstream pangolin + gerbil + traefik roles
  • Gerbil carries the host port mappings
  • Traefik shares Gerbil’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-config service writes the static traefik files into /srv/pangolin/config/traefik/
  • create-pangolin-network makes the shared Podman network once
  • virtualisation.oci-containers defines 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, and traefik all show running
  • the host is listening on 80, 443, 51820/udp, and 21820/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:

  • Newt stays 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 resources for browser-facing access
  • private resources for client-only access

Start with one browser-facing app.

Use a public resource like this:

FieldPlaceholder valueWhy it looks like this
Sitehome-siteThe resource lives behind the home-side connector.
Public hostnamebookmarks.example.comBrowser-facing DNS name.
Backend targethttp://192.0.2.40:9090Placeholder internal service target.
Auth posturerequire Pangolin loginKeep 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:

FieldPlaceholder valueWhy it looks like this
Sitehome-siteThe target still lives behind the same connector.
Resource labelinfra-sshHuman-readable but generic.
Destination192.0.2.10:22Placeholder SSH target only.
Access policyyour admin user or role onlyKeep 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:

  1. Visiting https://bookmarks.example.com should hit Pangolin’s front door before it hits the app.
  2. 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.
  3. The private admin target should require the Pangolin client path.
  4. 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 NixOS edge 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.

Source Notes