Running the edge on a VPS
ngstoned is the half of ngstone that lives on the public internet. This is a runbook: work top to bottom on a fresh Linux server and you end with a managed service holding valid certificates, an open control plane, and an agent tunnelling through it. example.com stands in for your domain throughout.
Fast path installer#
On a fresh Ubuntu or Debian VPS, create the apex and wildcard DNS records first, then install the edge from a checkout. The installer downloads the release binary, verifies its checksum, creates the root-only secrets file, installs the hardened systemd unit, configures UFW, and starts ngstoned:
$ git clone https://github.com/emilio-kariuki/ngstone.git /opt/ngstone$ cd /opt/ngstone$ sudo ./deploy/install-edge.sh \ --domain example.com \ --email you@example.com \ --acme-ca productionIt prompts for a narrowly scoped Cloudflare DNS token without echoing it, and prints the generated agent token once. Use --acme-ca staging while validating DNS and promote to production after the health check passes. The installer does not create DNS records, so the apex and wildcard must already resolve to the VPS.
Uninstalling#
From the checkout, remove the service, binary, and root-only secrets:
$ sudo ./deploy/uninstall.shCertificate and ACME state is preserved by default under /var/lib/private/ngstoned, so a later reinstall can reuse it. To delete that state and remove matching public UFW allow rules, opt in explicitly:
$ sudo ./deploy/uninstall.sh --remove-state --remove-firewall-rulesSSH access is kept
80/tcp, 443/tcp, and 4443/tcp. SSH rules and the source checkout are left in place. If another service uses the same allow rule, review it before selecting this option. Use --yes only for a deliberate non-interactive uninstall.Prerequisites#
- 1
A Linux VPS with a public IP
Anything that runs a static Go binary. It needs a stable public address — every DNS record below points at it — and root orsudofor installing a systemd unit and binding privileged ports. - 2
A domain you control
A registered domain, hereexample.com. Tunnels get hostnames one label below it, such asamber-fox-42.example.com. - 3
The zone hosted on Cloudflare
Certificates are issued through the ACME DNS-01 challenge, andgithub.com/libdns/cloudflareis the only DNS provider compiled intongstoned. The domain's nameservers must therefore point at Cloudflare and the zone must be active there before anything below will work. - 4
A machine to run the agent
Your laptop, with thengstonebinary installed — see Installation.
DNS records#
Create two records in the example.comzone, both pointing at the VPS's public IP.
4443, which is what the agent dials, and the apex certificate that secures it.amber-fox-42.example.com at bind time and never touches DNS, so every possible tunnel name has to already resolve to the VPS through this one record.Use AAAA instead if the VPS is IPv6-only, or add both.
Set both records to DNS only (grey cloud)
:4443. ngstoned terminates its own TLS; proxying in front of it breaks both certificate validation and the tunnel.The Cloudflare API token#
The edge needs to create and delete _acme-challenge TXT records in the one zone. Create a scoped API token for exactly that — never the Global API Key, which authenticates everything on the account.
- 1
Open the token editor
Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom Token. - 2
Grant one permission
Zone/DNS/Edit. Nothing else. - 3
Scope it to one zone
Zone Resources:Include/Specific zone/example.com. Not “All zones”. - 4
Keep the value
Cloudflare shows the token once. It goes into the edge's environment file asNGSTONE_CF_API_TOKENbelow.
A token scoped this way can write challenge records in one zone and nothing more — it cannot read other zones, change nameservers, or touch account settings. If it leaks, the blast radius is one zone's DNS.
Two certificates, not one#
ngstoned calls ManageSync(ctx, ["*.example.com", "example.com"]) once, which reads like a single multi-domain certificate. It is not. certmagic does not obtain multi-SAN certificates: that call places two independent ACME orders and produces two certificates with two independent renewal schedules.
This is forced anyway, because a wildcard covers exactly one left-most label (RFC 6125) — *.example.com does not match SNI example.com. The wildcard serves public tunnel traffic on :443; the apex certificate serves the control plane on :4443, where agents connect. Both names validate through the same _acme-challenge.example.com TXT record.
A healthy wildcard can hide a dead apex
ngstoned checks at startup that an apex certificate is loadable with at least 24 hours of life left before accepting on :4443, and exits loudly rather than listening with a certificate it cannot serve. /healthzreports each certificate's expiry separately — check both, not just one.Generating an auth token#
There is no ngstone token command
$ TOKEN=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')$ HASH=$(printf '%s' "$TOKEN" | shasum -a 256 | cut -d' ' -f1) # sha256sum on Linux$ echo "$TOKEN" # keep this for 'ngstone auth' on the agent machine$ echo "$HASH" # this goes to the edgeThe hash must be 64 hex characters; ngstoned refuses to start otherwise. There is exactly one token per edge — rotating it means changing the hash and restarting, which invalidates every agent at once.
The edge reads three values from its environment. Put them in /etc/ngstoned/env, mode 0600, owned by root:
NGSTONE_TOKEN_SHA256=3b1f0c2d…64 hex characters…9a7eNGSTONE_CERT_EMAIL=you@example.comNGSTONE_CF_API_TOKEN=cf_fake_token_value_goes_here-token-sha256; the environment wins when both are set.-cf-api-token; the environment wins when both are set.ngstoned directly — the unit interpolates it into -cert-email, which is required unless -insecure. It is the ACME account contact, not a secret.Never pass secrets as ExecStart flags
/proc/<pid>/cmdline is world-readable, so any local user can read a flag value with ps auxww — but not the process environment. That is precisely why ngstoned prefers $NGSTONE_TOKEN_SHA256 and $NGSTONE_CF_API_TOKEN over their flags. Keep both out of ExecStart.Installing the binary#
Release archives contain both binaries — ngstone and ngstoned. On the VPS, download the archive matching its architecture, verify the checksum, and install the edge binary:
$ VERSION=v0.2.0$ ARCHIVE=ngstone_linux_amd64.tar.gz # ngstone_linux_arm64.tar.gz on arm64$ BASE=https://github.com/emilio-kariuki/ngstone/releases/download/$VERSION$ curl -fsSLO "$BASE/$ARCHIVE"$ curl -fsSLO "$BASE/checksums.txt"$ sha256sum --ignore-missing -c checksums.txt$ tar -xzf "$ARCHIVE" ngstoned$ sudo install -m 0755 ngstoned /usr/local/bin/ngstoned$ ngstoned -versionRunning it under systemd#
Install the unit, create the environment file from the section above, and start the service:
[Unit]Description=ngstoned edge serverAfter=network-online.targetWants=network-online.target [Service]Type=simpleExecStart=/usr/local/bin/ngstoned \ -domain example.com \ -cert-email ${NGSTONE_CERT_EMAIL} \ -acme-ca staging \ -cert-storage ${STATE_DIRECTORY}/certsEnvironmentFile=/etc/ngstoned/envRestart=alwaysRestartSec=2s StateDirectory=ngstonedStateDirectoryMode=0700 AmbientCapabilities=CAP_NET_BIND_SERVICENoNewPrivileges=yesProtectSystem=strictProtectHome=yesPrivateTmp=yesPrivateDevices=yesProtectKernelTunables=yesProtectKernelModules=yesProtectControlGroups=yesRestrictSUIDSGID=yesRestrictRealtime=yesLockPersonality=yes DynamicUser=yes LimitNOFILE=65536 [Install]WantedBy=multi-user.target$ sudo mkdir -p /etc/ngstoned && sudo chmod 700 /etc/ngstoned$ sudo $EDITOR /etc/ngstoned/env$ sudo chmod 600 /etc/ngstoned/env$ sudo systemctl daemon-reload$ sudo systemctl enable --now ngstoned$ systemctl status ngstonedDynamicUser=yes gives the service a transient, unprivileged UID allocated fresh on every start, and AmbientCapabilities=CAP_NET_BIND_SERVICE lets that unprivileged user still bind :80 and :443. The certificate cache has to survive across those changing UIDs, which is what StateDirectory=ngstoned is for: systemd creates /var/lib/private/ngstoned (with a /var/lib/ngstoned symlink) and re-chowns it to the current UID on every start. Point -cert-storage at ${STATE_DIRECTORY}/certs so the two always agree.
Do not substitute ReadWritePaths= for StateDirectory=
ReadWritePaths= gets no ownership handling from systemd, so a freshly assigned dynamic user cannot read the existing cache. The certificates are silently lost and re-issued on every restart, burning Let's Encrypt rate limits until issuance starts failing.-acme-ca defaults to staging and the unit above keeps it there deliberately. Staging certificates are not trusted by browsers but exercise the identical code path, so a misconfiguration costs nothing. Once systemctl status ngstoned is clean and /healthz reports sane expiry dates for both certificates, switch the unit to -acme-ca production and restart. Do not delete the staging certificates first — certmagic namespaces storage per issuer, so the two live in separate subtrees and cannot collide.
Firewall#
Four listeners, three of them public. The addresses below are the defaults; each has a flag if you need to move it.
301 redirects to HTTPS. DNS-01 means no ACME traffic ever arrives here, so you can close it if you do not want the redirect.expvar, /healthz, and pprof. Loopback only — never open this port. Reach it over an SSH tunnel instead. An empty value disables it.$ sudo ufw allow 443/tcp$ sudo ufw allow 4443/tcp$ sudo ufw allow 80/tcp$ sudo ufw enableTCP tunnels#
Off by default. An operator opts in with -tcp-ports, which lets agents run ngstone tcp — see TCP tunnels for the client-side guide.
10000-10999. Empty disables TCP tunnels.This range bypasses Traefik entirely
-tcp-ports are bound directly by ngstoned — real listeners, not routed through the :443 reverse proxy or any front proxy sitting in front of it. The range needs its own firewall opening, the same way 443/tcp does above:$ sudo ufw allow 10000:10999/tcpBehind an existing reverse proxy#
Skip this section if ngstoned owns :443 directly. If something else already holds that port, the edge still has to terminate its own TLS — it serves certificates it manages itself — so the proxy must pass the connection through at the TCP layer rather than terminating it. With Traefik that is an SNI-routed TCP router with tls.passthrough:
tcp: routers: ngstoned: entryPoints: - websecure rule: "HostSNIRegexp(`^.+\\.example\\.com$`)" tls: passthrough: true service: ngstoned services: ngstoned: loadBalancer: proxyProtocol: version: 2 servers: - address: "ngstoned:443"Passthrough is not transparent at the TCP layer. Traefik accepts the browser's connection and opens a new one to ngstoned, so without further configuration the edge sees the proxy's address as the client address — often a private one such as 10.0.1.69. Every X-Forwarded-For the edge stamps is then wrong, the local app cannot trust the forwarding headers, and the per-IP rate limiter collapses the entire internet into one shared bucket, so a single abusive client either escapes limiting or locks everyone out.
The fix is the PROXY protocol: the proxy prefixes each connection with the original endpoints and the edge uses them as the client address. That is the proxyProtocol.version: 2 block above, plus -proxy-protocol on the edge:
ExecStart=/usr/local/bin/ngstoned -proxy-protocol -public 127.0.0.1:443 ...Both sides must change together
-proxy-protocol on, the edge rejects any connection to the public listener that does not begin with a valid PROXY header; with it off, a proxy that sends one produces a TLS handshake failure. Enabling either side alone breaks every public request. Change the unit and the Traefik service in the same deployment.The public port must then be reachable only from the proxy
X-Forwarded-For, and rate-limits on. Bind the public listener to the proxy-facing interface only (-public 127.0.0.1:443, or a Docker network address) and make sure no firewall rule exposes it. Leaving the flag off — the default — preserves the normal behaviour exactly.If binding to a loopback/Docker-only address isn't possible, -proxy-protocol-trustedrestricts which peers the edge accepts a PROXY header from — it closes the connection immediately if the socket's real peer address isn't in the list, before even trying to parse a header:
ExecStart=/usr/local/bin/ngstoned -proxy-protocol -proxy-protocol-trusted 10.0.1.69/32 ...This also matters for per-tunnel --allow-cidr (see Access control): that check trusts whatever the edge believes is the source IP, and behind a proxy that's whatever the PROXY header says. Left empty, the edge accepts a PROXY header from any peer, so a direct client could forge one and spoof its way past an allowlist unless the public port is firewalled to the proxy alone.
The flag applies to the public listener only. Do not put the control listener on :4443behind a PROXY-protocol proxy: it does not parse the header and every agent connection would fail. Version 2 is Traefik's default when proxyProtocol is present at all; the edge also accepts the text-based version 1, and the LOCAL command used by health checks, falling back to the real socket address.
Connecting an agent#
On the agent machine, point ngstone at your edge and hand it the plaintext token. Both flags matter for a custom domain: -server is the address to dial, and -server-name is the SNI and certificate name to verify — it defaults to ngstone.site, so verification fails against any other domain without it.
$ ngstone auth "$TOKEN" -server example.com:4443 -server-name example.comverified against example.com:4443 and savedauth persists the token and server address to the config file, but not -server-name — pass it again on every ngstone port invocation:
$ ngstone port 3000 -server-name example.com$ ngstone port 3000 -server-name example.com --subdomain demoThe agent prints the assigned URL. Verify it end to end from a third machine:
$ curl -i https://demo.example.com/HTTP/2 200Staging certificates fail curl
-acme-ca staging is in effect the chain is not in any trust store, so curl reports an unknown issuer. Use curl -k to confirm routing works, then promote to production for a certificate real clients accept.Troubleshooting#
Everything the edge does is logged to the journal:
$ journalctl -u ngstoned -f$ journalctl -u ngstoned -n 200 --no-pagerThe ops endpoint is loopback-only by design; reach it over SSH. /healthz reports the apex and wildcard expiry separately, which is the fastest way to tell a half-working certificate state from a healthy one:
$ ssh -L 4041:127.0.0.1:4041 root@example.com$ curl http://127.0.0.1:4041/healthzZone:DNS:Edit on this zone. A token scoped to the wrong zone fails at the TXT-record write, which the log names explicitly.:4443 accepts connections and fails loudly if no apex certificate is loadable with 24 hours of life remaining. Fix issuance first; the wildcard being healthy proves nothing about the apex.-token-sha256 (or $NGSTONE_TOKEN_SHA256) is required always, and -cert-email plus -cf-api-token are required unless -insecure. Each missing value is named in a single error line before exit.echo instead of printf is the usual cause — and confirm the edge picked up the new value with a restart.4443 is closed, or the agent is dialling the wrong host. Confirm the firewall rule and the saved address with ngstone config get server.-server-name example.com: the default SNI is ngstone.site, which the apex certificate does not cover. It is also what a staging-issued apex certificate looks like from a client with a normal trust store.Restarting three times in a row should produce no new ACME traffic in the logs. If it does, the certificate cache is not persisting and the StateDirectory configuration is wrong.