Back to article index
TRANSMISSION / CYAN33 min read

StunDeck on fnOS or Docker: Stable Cloudflare 302 for Dynamic STUN Ports

Install the StunDeck FPK on fnOS or deploy with Docker, then configure a least-privilege Cloudflare token, proxied DNS, and Single Redirect to keep one hostname synchronized with a dynamic STUN endpoint.

#StunDeck#fnOS#FPK#Docker#STUN#UPnP#Cloudflare#302#Homelab

Video explainer / 16:9

StunDeck Complete Walkthrough: Docker, Cloudflare 302/307, and STUN Diagnostics

A concise Chinese-narrated walkthrough with embedded Chinese subtitles, covering deployment, first-run setup, a least-privilege token, redirect services, UPnP/NAT-PMP, diagnostics, and off-network verification.

6:15Chinese narration10 chapters

Press play for the full explainer. Seeking, picture-in-picture, and fullscreen are supported.

The central goal of this guide is not merely to make STUN work. Users should always open one stable hostname. When StunDeck discovers a new public IP or dynamic port, it updates a Cloudflare Single Redirect; Cloudflare returns 302 (or 307), and the client follows that response to the current public mapping.

The deployment still begins with Docker, but the order matters: deploy StunDeck → create the Cloudflare token → save the Cloudflare connection → create a Redirect service → start STUN/UPnP → verify both the redirect and the second hop. Cloudflare must be configured before the service so that the connection is selectable and automatic synchronization can be tested.

Verified result:

  • Debian 13.6 on amd64;
  • Docker Engine 29.7.1 and Docker Compose 5.3.1;
  • the StunDeck image built from public commit bcb5a1a;
  • no proxy in the Docker service environment and empty proxy variables inside the container;
  • NATMap obtained a mapping and UPnP opened the first-hop listener;
  • an independent off-network host reached the dynamic endpoint and received HTTP 200; the stable entry returned HTTP 302 with a Location header;
  • no real credential, token, private address, public IP, or dynamic port appears in this article or its images.

STUN is not a relay. It cannot turn symmetric NAT or restrictive CGNAT into a public entry point. This guide documents a network where the final off-network test succeeded; your access network may have a different boundary.

Install the verified FPK on fnOS

Store status (August 26, 2026): StunDeck has been submitted for review in the fnOS App Center. Until it is approved and publicly listed, you can install the verified release FPK manually. The fnOS package is an FPK, not an Android APK.

The current release is StunDeck v0.1.1 for linux/amd64 and linux/arm64. This blog hosts a verified copy and links to the GitHub Release as a second source:

To install it:

  1. Download the FPK and verify its SHA-256 locally.
  2. In fnOS, open App Center → Settings → Manually install an app.
  3. Select stundeck-fpk-0.1.1.fpk and choose the timezone. Enable secure cookies only when access goes through an HTTPS reverse proxy.
  4. The first installation selects an unused port from 20000–59999 and persists it; upgrades and restarts keep the same port.
  5. Open StunDeck from the fnOS desktop, create the administrator, and then add Cloudflare tokens, mapping services, and webhooks.

The FPK pulls the immutable image ghcr.io/nciae-zyh/stundeck:v0.1.1, so the NAS must be able to reach GHCR during its first installation. No Cloudflare token, webhook secret, or other application secret is bundled in or collected by the installer.

fnOS manages the package configuration and persistent directories. Application data lives under ${TRIM_PKGVAR}/data. Before an upgrade, migration, or reinstall, back up stundeck.db and master.key together. Without master.key, encrypted Cloudflare tokens, webhook secrets, and TOTP secrets in the database cannot be recovered. Uninstall keeps the data by default and removes it only after an explicit “permanently delete” choice.

Understand the primary path first

Client always opens a stable hostname
  └─ Cloudflare proxied DNS (orange cloud)
       └─ Single Redirect returns 302 / 307 + Location
            └─ current public IP:dynamic port
                 └─ router UPnP / NAT-PMP / manual forward
                      └─ StunDeck + NATMap
                           └─ private target service

The hostname is stable; the public endpoint is not. Cloudflare only handles the first hop. It returns a redirect and does not provide a Tunnel, relay, WAF, or Access protection for the second hop. The browser connects directly to the public IP and dynamic port in Location.

Treat these as two independent success conditions:

  1. the stable hostname returns the expected 302/307, proving that DNS and Single Redirect are synchronized;
  2. following Location returns the application response, proving STUN, gateway forwarding, firewall rules, and the private target all work.

A passing first hop alone is not proof that the service is reachable.

1. Prerequisites

Use a Linux host with:

  • Debian 13/12 or another Docker-supported distribution;
  • amd64 or arm64;
  • a stable or identifiable LAN address;
  • access to the private target service;
  • a router where you can enable scoped UPnP or configure a manual forward;
  • a disposable test service without private data.

The official Lucky STUN guide targets NAT1 and explains that a LAN deployment also needs UPnP, a port forward, or a DMZ arrangement. NAT labels differ between vendors, so use logs and a real off-network connection as the final evidence.

Do not expose your NAS console, router interface, or StunDeck itself as the first test. Start with a static page or temporary HTTP origin.

2. Install Docker Engine and Compose

The following follows Docker's official Debian instructions. Compose is installed as the maintained docker-compose-plugin, not the legacy standalone binary.

sudo apt update
sudo apt install -y ca-certificates curl

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<'EOF'
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: trixie
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install -y \
  docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

Replace trixie and amd64 when your host differs:

. /etc/os-release && echo "$VERSION_CODENAME"
dpkg --print-architecture

Keep download proxies temporary

During this deployment, one direct request to Docker's GPG endpoint was reset. A proxy used only for installation should not be placed in /etc/environment or left in the Docker systemd service.

PROXY_URL=http://192.168.50.9:7890

sudo curl --proxy "$PROXY_URL" -fsSL \
  https://download.docker.com/linux/debian/gpg \
  -o /etc/apt/keyrings/docker.asc

sudo env http_proxy="$PROXY_URL" https_proxy="$PROXY_URL" apt update
unset PROXY_URL

If the repository remains unreliable, use Docker's official “Install from a package” path, verify the matching .deb files, and install them locally. Do not use binaries from an unknown mirror.

Verify the installation:

sudo docker version
sudo docker compose version
sudo systemctl is-enabled docker
sudo systemctl is-active docker
sudo systemctl show docker -p Environment

The final command returned Environment= in this deployment. The container receives a second independent proxy check later.

Rendered from the real command output. Hostname, login identity, LAN address, and the temporary download proxy were removed.

3. Create the Compose file

sudo install -d -m 0755 /opt/stundeck
cd /opt/stundeck

Save this as /opt/stundeck/compose.yaml:

services:
  stundeck:
    image: ghcr.io/nciae-zyh/stundeck:latest
    network_mode: host
    restart: unless-stopped
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:size=16m,mode=1777
    volumes:
      - stundeck-data:/var/lib/stundeck
    environment:
      STUNDECK_LISTEN: 0.0.0.0:8080
      STUNDECK_DATA_DIR: /var/lib/stundeck
      STUNDECK_SECURE_COOKIES: "false"
      TZ: Asia/Shanghai
      HTTP_PROXY: ""
      HTTPS_PROXY: ""
      ALL_PROXY: ""
      http_proxy: ""
      https_proxy: ""
      all_proxy: ""

volumes:
  stundeck-data:

network_mode: host lets NATMap and UPnP use the real Linux network stack. The container stays read-only, drops every capability, enables no-new-privileges, and persists only /var/lib/stundeck. Proxy variables are explicitly empty.

Use STUNDECK_SECURE_COOKIES: "false" only for direct LAN HTTP during initial setup. Set it to "true" behind HTTPS. Do not use Docker Desktop as a production STUN gateway, and do not add privileged: true.

4. Pull, start, and verify

cd /opt/stundeck
sudo docker compose config --quiet
sudo docker compose pull
sudo docker compose up -d --no-build
sudo docker compose ps

The container became healthy in about ten seconds. Check the API and container environment:

curl -fsS http://127.0.0.1:8080/api/v1/health

sudo docker inspect stundeck-stundeck-1 \
  --format '{{range .Config.Env}}{{println .}}{{end}}' \
  | grep -i proxy

The health endpoint should report status: ok. The six proxy variables may appear in the second command, but every value must be empty. Remove any temporary daemon proxy and restart Docker before starting StunDeck.

The real deployment became healthy, returned an OK health response, and had no proxy value inside the container. Host and container identifiers were removed.

5. First-run security

Open http://LAN_SERVER_IP:8080 from the LAN, then:

  1. create a unique administrator and a strong password of at least 12 characters;
  2. choose the LAN access mode;
  3. add an allowed Host only when its reverse-proxy name is known;
  4. configure the Host allowlist and authenticator 2FA under Security;
  5. never publish port 8080 directly through STUN.

A real first-run page from a disposable empty-data environment. No username or password was entered.

The owner approved publishing stun-lab-20260802.sparkles-editor.com as the Host example. It was entered only in the disposable capture environment and was not saved. No DNS record, Zone/Rule ID, token, TOTP secret, or administrator identity is visible.

Use Cloudflare Tunnel, a VPN, or an authenticated HTTPS reverse proxy when remote control-plane access is required.

6. Create a least-privilege Cloudflare API token first

Single Redirect only handles traffic that reaches Cloudflare, so the entry hostname must exist in the target Zone and use proxied DNS (orange cloud). This guide does not reveal DNS values, Zone IDs, Ruleset IDs, or unrelated hostnames.

Open the Cloudflare user menu, go to Profile → API Tokens, and select Create Token.

Choose Create Custom Token. Do not use the Global API Key.

Use a recognizable name such as StunDeck Redirect · sparkles-editor.com, then grant exactly three Zone permissions:

ResourcePermissionLevelPurpose
ZoneZoneReadFind and validate the target Zone
ZoneDNSEditCreate or maintain the proxied entry record
ZoneSingle RedirectEditCreate and update the 302/307 rule

Under Zone Resources, choose Include → Specific zone → sparkles-editor.com. Do not authorize every Zone. If StunDeck will not manage DNS, omit DNS Edit and keep the proxied entry record under your own management.

Client IP filtering only fits a fixed management egress. Hard-coding a residential public IP can prevent later synchronization when that address changes. You may also assign an expiry date that matches your maintenance cycle.

Select Continue to summary and verify that the target Zone has only these three permissions:

Finally select Create Token. The plaintext value is displayed only once. Store it in a password manager and paste it into StunDeck; never put it in Compose, Markdown, Git, logs, screenshots, or chat. To avoid creating an unnecessary long-lived credential, the captures in this guide stop at the review page and contain no token value.

7. Save and test the Cloudflare connection in StunDeck

Open the StunDeck Cloudflare page before creating the mapping service:

  1. enter a recognizable connection name;
  2. paste the new API token;
  3. select Test permissions;
  4. save only after Zone, DNS, and Single Redirect checks pass.

A real page from the rebuilt environment. The field is empty and the capture contains no token, account identity, DNS value, or resource ID.

StunDeck encrypts the credential with its local master key. A usable backup must include both the SQLite database and master.key.

8. Create the 302-focused Redirect service

Use a disposable HTTP target and configure the service like this:

Name: Home NAS (example)
Private target: 192.168.1.20 (example)
Target port: 80 (example)
Protocol: TCP
Listener: 0 (automatic)
Router mapping: UPnP (recommended)
Publishing: Cloudflare Redirect
Cloudflare connection: the connection saved above
Entry hostname: stun-lab-20260802.sparkles-editor.com
Target scheme: HTTP
Redirect status: 302 Temporary
Manage DNS automatically: enabled
Preserve path: enabled
Preserve query: enabled

A real documentation form. Private addresses and ports are placeholders, no Cloudflare connection was saved, and the form was not submitted. The actual service card above it was cropped out.

Choose 302 or 307 deliberately

StatusClient behaviorRecommended use
302 TemporaryTemporary redirect; some clients change POST to GETPages, downloads, and primarily GET-based services; the default here
307 Temporary RedirectPreserves the HTTP method and request bodyAPIs, webhooks, uploads, and POST-sensitive traffic

Both are temporary. Do not use 301/308 for an endpoint that is expected to change.

With Preserve path and Preserve query enabled, /files?a=1 is appended to the current target. An HTTPS target also needs an origin hostname covered by the target certificate; redirecting directly to an IP usually fails hostname validation.

Automatic DNS only changes records that StunDeck created and marked as managed. If an unmanaged record already uses the hostname, synchronization stops instead of overwriting it.

9. Start the service and run STUN / UPnP diagnostics

A healthy sequence is:

Stopped → Probing → Router mapping ready → Cloudflare synchronized

StunDeck maps the traversal listener to the same local listener port, then writes the public IP and port discovered by NATMap into the Single Redirect target.

Check each layer:

  • Docker daemon and container proxy environments are empty;
  • NATMap is executable;
  • the private target is reachable;
  • Binding succeeds for the selected protocol;
  • the correct UPnP IGD or NAT-PMP gateway is found;
  • the router mapping is installed;
  • the public mapping is recent;
  • Cloudflare DNS and the Redirect Rule are synchronized or report a clear ownership conflict.

10. Verify both the fixed-hostname redirect and the second hop

First, prevent curl from following the redirect so that only the Cloudflare hop is tested:

curl -sS -o /dev/null -D - --max-redirs 0 \
  https://stun-lab-20260802.sparkles-editor.com/

Expect HTTP/2 302—or 307 if selected—and a Location header. Cloudflare's Rules page should show the same hostname, status, and an active rule:

A real rule row. Only the approved public entry hostname and 302 status remain; the dynamic endpoint, account details, other rules, and resource IDs are excluded.

Then test from mobile data or an independent off-network host:

curl -sS -L --max-time 15 -o /dev/null \
  -w 'status=%{http_code} redirects=%{num_redirects}\n' \
  https://stun-lab-20260802.sparkles-editor.com/

The target result is status=200 redirects=1. During the rebuild, an independent external host received HTTP 200 from the new dynamic mapping, and the fixed entry returned 302 with Location. The older isolated service represented by the rule screenshot was later retired, so that hostname now proves only that the first hop still works; it is not a promise that the retired second hop remains online.

Never publish the real Location, public IP, or dynamic port.

11. Troubleshooting

Mapping exists, but the browser shows ERR_EMPTY_RESPONSE

Check, in order:

  1. the private target is reachable from the StunDeck host;
  2. the application is not bound only to 127.0.0.1;
  3. Compose uses host networking;
  4. the service uses the intended gateway mode;
  5. UPnP/NAT-PMP installed the mapping on the real egress gateway;
  6. an upstream modem or second router is not blocking the path;
  7. the test comes from another network, not from a LAN without NAT loopback.

STUN is unstable

  • confirm both daemon and container proxy environments are clean;
  • compare behavior with transparent proxy, policy routing, and multi-WAN disabled;
  • keep listener ports unique;
  • never treat a dynamic mapping as a fixed endpoint;
  • inspect mapping timestamps and the NATMap process;
  • use Tunnel, VPN, or a relay when the NAT boundary is incompatible.

The stable hostname does not return 302/307

Check, in order:

  1. the entry DNS record is proxied;
  2. the token is scoped to the correct Zone;
  3. Zone Read and Single Redirect Edit are present;
  4. DNS Edit is present when automatic DNS is enabled;
  5. no unmanaged DNS record or Redirect Rule owns the same hostname;
  6. an active rule exists in the http_request_dynamic_redirect phase.

The Cloudflare entry redirects, but the second hop fails

Test the dynamic endpoint directly. If that fails, debug STUN, gateway mapping, firewall, and the private target—not the redirect rule.

12. Upgrade, backup, and remove

Back up both the SQLite database and master.key. Check the real volume name first:

sudo docker volume ls
sudo docker volume inspect stundeck_stundeck-data

Upgrade:

cd /opt/stundeck
sudo docker compose pull
sudo docker compose up -d --no-build
sudo docker compose ps

Stop while retaining data:

sudo docker compose down

Delete the volume only when recovery is no longer required:

sudo docker compose down -v

down -v removes the administrator, services, events, encrypted Cloudflare credentials, and local master key.

Sources

The useful result is evidence from five layers: the target is reachable, STUN Binding succeeds, the gateway mapping is installed, the stable Cloudflare entry returns 302/307, and an independent client follows the redirect successfully. The hostname solves entry-point stability; it does not turn the second hop into a protected tunnel.

End of signal
READER CHANNEL

Comments

00
No comments yet. Be the first to respond.