A controller published on every interface, with email linking on by default
ZTNET - ZeroTier Web UI for Private Controllers with Multiuser and Organization Support.
At a glance
- What is it?
- ztnet is a Next.js web interface for running your own ZeroTier network controller, with organization and multi-user support, shipped as a three-container compose file. The configuration is where the interest is: the controller binds on all interfaces by default with the loopback alternative left commented out, the management plane trusts a hard-coded eight-address subnet that Docker has to agree with, and the authentication defaults include a flag literally named for being dangerous.
- Who is it for?
- It fits an operator who wants a controller their team can use through a browser, who already understands that a controller is a privileged position on a network, and who is willing to edit a compose file before the first boot rather than after. It does not fit a quick trial on a shared or untrusted network, because the out-of-box arrangement serves the controller over plain HTTP on every interface with permissive OAuth defaults.
- Can I use it commercially?
- Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 5, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The controller binds on every interface and the fix is a comment
The compose file publishes the application port in full, `- 3000:3000`, and the next line is the alternative left switched off:
ports:
- 3000:3000
# - 127.0.0.1:3000:3000 <--- Use / Uncomment this line to restrict access to localhost onlySo a fresh install serves a network controller on every interface the host has, and the remedy is present in the file as a comment rather than as the default. Two related values are worth reading next to it. `NEXTAUTH_URL` is set to `http://localhost:3000` and carries a note that it must be changed to the canonical address of the site, which on a LAN deployment means plain HTTP to a controller. And the secret is guarded properly: the compose file uses `NEXTAUTH_SECRET:?Set NEXTAUTH_SE...`, which refuses to start when the variable is unset, alongside a comment telling you to generate one with `openssl rand -hex 32` and to keep the value you already have when updating an existing install rather than replacing it.
The management plane trusts a hard-coded eight-address range
The ZeroTier container receives `ZT_ALLOW_MANAGEMENT_FROM=172.31.255.0/29`, a slash-29 block, so eight addresses out of a specific /24 inside 172.31.255.x. Nothing in the compose file reserves that subnet for the app network, so whether the container will accept controller requests depends on where Docker happens to place the bridge, and Docker does not guarantee that range. If it lands elsewhere, management requests are refused and the controller and node appear to be configured correctly while nothing joins. The same container is also given `ZT_OVERRIDE_LOCAL_CONF=true`, two added capabilities, `NET_ADMIN` and `SYS_ADMIN`, and the host's `/dev/net/tun` device, with the ZeroTier port published as `9993:9993/udp`. That combination is what makes it a full member node rather than a relay, which is a meaningful privilege to grant a container on a network that is otherwise trusted. The README does not explain the intended topology, so the reasoning behind the range is not recoverable from the repository.
A third-party node image and a floating tag for the application
Three images are pulled. PostgreSQL is pinned, `postgres:15.2-alpine`, with the password set to the literal string `postgres` for the superuser, which is unremarkable for a compose file whose database port is never published. The node is not the vendor image: it is `zyclonite/zerotier:1.14.2`, a third-party build pinned to one release. That choice means the piece of the system that actually joins your network comes from a community image, and a version bump to the underlying software waits on that image being republished. The application itself is the loosest of the three, `sinamics/ztnet:latest`, so a `docker compose pull` moves the controller between versions with nothing in the file recording which one you were running. The zeroTier data volume is shared between the node and the application, and the application container mounts it as well as the node does.
Two planet-generator binaries are committed, and their credit is hidden in a comment
The build copies two prebuilt executables out of the repository rather than compiling them:
COPY ztnodeid/build/linux_amd64/ztmkworld ztmkworld_amd64
COPY ztnodeid/build/linux_arm64/ztmkworld ztmkworld_arm64
RUN \
case "${TARGETPLATFORM}" in \
"linux/amd64") cp ztmkworld_amd64 /usr/local/bin/ztmkworld ;; \
"linux/arm64") cp ztmkworld_arm64 /usr/local/bin/ztmkworld ;; \
*) echo "Unsupported architecture" && exit 1 ;; \
esac && \
chmod +x /usr/local/bin/ztmkworldSo a directory named `ztnodeid/` carries binaries of a separate tool, exactly two architectures are supported and anything else fails the build, and the binaries ship inside a GPL-3.0 project. The attribution for that component is present in the README source but wrapped in an HTML comment block, so it does not render on the page. It names the tool, credits a Go re-implementation of a ZeroTier original to Patrick Young, and notes that his version is GPLv3 while the project is GPLv3. If that notice has been moved elsewhere it is fine, but as the file stands the credit for a vendored binary is invisible, which is worth putting right in a copyleft project.
The image generates its database client with a different Prisma version
The manifest declares `@prisma/client` at 6.19.3 and runs `prisma generate` on postinstall. The Dockerfile instead fetches a specific older release at build time with `npx [email protected] generate`, which resolves outside the dependency tree and outside the lockfile. The application therefore ships a generated client made by a different minor series than the one it depends on, and the mismatch is invisible because both steps report success. Two smaller things sit beside it. The dependency stage copies `package.json` together with patterns for yarn, npm and pnpm lockfiles, then branches on which exists, so the package manager a build uses depends on what a fork happens to have committed. And the build runs with `SKIP_ENV_VALIDATION=1`, so the environment schema is not checked while the image is produced; the real guard is the compose file refusing to start without a session secret rather than the build refusing to finish without one.
The authentication defaults include a flag named for being dangerous
The example environment file is explicit about its OAuth section, and the first setting is `OAUTH_ALLOW_DANGEROUS_EMAIL_LINKING=true`. The name is the warning. With that on, an account at one provider can be linked to an existing local account on the basis of a matching email address, which is the standard way an external login becomes an account takeover. Two more settings widen the door: `OAUTH_EXCLUSIVE_LOGIN=false` means provider login is not the only way in, and `OAUTH_ALLOW_NEW_USERS=true` means an external login can create an account rather than needing an invitation. None of these are secrets, and none is undocumented, but all three ship enabled. The dependency list also shows two generations of authentication library alongside the NextAuth-shaped variables in both the compose file and the example, so knowing which one actually validates a session from the manifest alone is not possible.
Handle-leak detection is in the dev script, and the manifest says 0.1.0
Tests are split across two Jest configurations, one for pages and one for the API, with a shared setup file. The difference between the two scripts is where the useful detail sits: `test:dev` runs the page suite with `--detectOpenHandles --verbose`, while `test` runs the page suite plainly and gives coverage to the API suite with `--ci --coverage`. So the check that finds leaked handles runs on a developer's machine rather than in the pipeline, which is the usual way a resource leak reaches production anyway. Linting is Biome over `src`, with format and format-fix variants, while styling is handled outside it through postcss and tailwind configuration and a `components.json` for the component registry, plus a style guide document. Two version facts sit in the open. `package.json` says version 0.1.0 while the tags run to v0.8.4, and the manifest is marked private, so the internal number carries no meaning for anyone outside the repository. And the example environment spells the Prisma shadow database `shaddow_ztnet` in both the variable name and the comment above it.
Editorial conclusion
It fits an operator who wants a controller their team can use through a browser, who already understands that a controller is a privileged position on a network, and who is willing to edit a compose file before the first boot rather than after. It does not fit a quick trial on a shared or untrusted network, because the out-of-box arrangement serves the controller over plain HTTP on every interface with permissive OAuth defaults. Before starting it, uncomment the loopback port mapping, check that your Docker subnet actually matches the management range the ZeroTier container is given, generate a unique session secret and keep it, and read what your OAuth provider lets you link to an existing account before enabling external login at all.
Frequently asked questions
What does ztnet do?
It is a self-hosted web interface for a ZeroTier network controller, with organization and multi-user support added for team environments. It runs as a Next.js application against a PostgreSQL database and a ZeroTier node container.
Is the ztnet web interface exposed by default?
Yes. The compose file publishes 3000:3000 with no loopback prefix and leaves the 127.0.0.1:3000:3000 alternative commented out with a note to uncomment it to restrict access to localhost.
Which ZeroTier image does ztnet use?
A third-party build, zyclonite/zerotier:1.14.2, rather than an official vendor image. The application itself is pulled as sinamics/ztnet:latest and the database as postgres:15.2-alpine.
What does ZT_ALLOW_MANAGEMENT_FROM control?
The compose file sets it to 172.31.255.0/29, so the node accepts controller management requests only from that eight-address range. The compose file does not reserve that subnet for the app network, so the value depends on where Docker places the bridge.
Does ztnet link external accounts by email?
The example environment ships OAUTH_ALLOW_DANGEROUS_EMAIL_LINKING=true, with OAUTH_EXCLUSIVE_LOGIN=false and OAUTH_ALLOW_NEW_USERS=true. All three are enabled by default and are documented in .env.example.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/sinamics-ztnet)