caddy-tailscale puts a Tailscale node inside the Caddy binary
A highly experimental exploration of integrating Tailscale and Caddy.
At a glance
- What is it?
- caddy-tailscale lets a Caddy server join a tailnet without a separate Tailscale client, through a network listener, a proxy transport, an authentication provider, and a subcommand. The project labels itself experimental, and the README says you may not need it at all.
- Who is it for?
- Adopt caddy-tailscale when installing the Tailscale client on a host is genuinely inconvenient, when you need several sites each acting as a separate tailnet node, or when you want a single self contained binary to ship. Do not adopt it for a machine where the standard client installs cleanly, because the README states the plugin is still very experimental and the supported path there is a plain Tailscale install with Caddy binding to the Tailscale interface.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 58 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The plugin bundles a tailnet node into the Caddy process
The core idea is containment. The Tailscale plugin for Caddy allows running a Tailscale node directly inside of the Caddy web server, so a caddy server can join your Tailscale network directly without needing a separate Tailscale client. Underneath, the project leans on tsnet, which appears as a dependency of the module and as a configurable port setting for nodes. The README is explicit that the plugin is still very experimental, and the badge in the project description links to Tailscale's own release stage documentation for experimental software. That label is not decoration. A plugin that opens its own network presence in your tailnet, registers nodes against a control server, and handles identity for authentication is doing several sensitive jobs at once, and the project asks you to keep that in mind.
Four plugins, not one: listener, transport, auth, and a subcommand
The README describes the project as a collection of plugins rather than a single integration, and lists four of them. A Caddy network listener serves sites privately on your tailnet, so a site can be reachable only from inside the tailnet. A Caddy proxy transport proxies requests to another device on your tailnet, which is the reverse direction. A Caddy authentication provider passes a user's Tailscale identity to an application, which is the piece that turns a request into a known user. And a Caddy subcommand sets up a reverse proxy quickly using either or both of the listener and the authentication provider. The four repository files at the top level map onto this structure: addresses.go, transport.go, auth.go, and command.go, alongside app.go and module.go for the app and module registration. The examples directory contains a matching set of Caddyfiles, simple, transport, auth, and proxyauth, one per way of using the plugin.
Building a Caddy binary that includes the plugin
The documented build path uses xcaddy to produce a Caddy binary with the plugin included, and the README pins a specific Caddy version in the command:
xcaddy build v2.9.1 --with github.com/tailscale/caddy-tailscaleThe alternative is to build the included cmd/caddy package yourself, which is what the Dockerfile does:
go build ./cmd/caddyThat distinction has a practical consequence. The go module declares a dependency on github.com/caddyserver/caddy/v2 at v2.10.2 and a Go version of 1.26.5, while the xcaddy example builds against Caddy v2.9.1, so the two documented paths are not the same software combination and the newer Caddy release line may carry API changes the plugin has not been adjusted for. If you are pinning a Caddy version for other reasons, check which one the plugin actually compiles against before you commit to either command.
Nodes are not registered until something actually uses them
The configuration model has a behaviour worth understanding before you debug anything. The tailscale global option in the Caddyfile only defines configuration values for Tailscale nodes. Nodes are not actually registered and connected to your tailnet until they are used, such as listening on the node's interface or using it as a proxy transport. That means a configuration that looks complete can register nothing at all, which is a common source of confusion when a node never appears in your tailnet admin console. Most options can be set at the top level, where they apply to all nodes, and they can also be set for a specific named node, which overrides the top level values. Named node configurations can be referenced elsewhere in the caddy configuration, so a single global block can serve several sites, each with its own hostname, tags, or state directory.
Auth keys, ephemeral nodes, and the Caddyfile global option
All configuration values are optional, though an auth key is strongly recommended. If no auth key is present in the config, one will be loaded from the default TS_AUTHKEY environment variable, and failing that the plugin logs an auth URL to the Caddy log that can be used to register the node. The auth key can optionally be an oauth client secret, which generates an auth key on demand, with the caveat that the tag registered with the oauth client secret must be provided in the caddy config, and that the ephemeral config option has no effect for oauth generated tokens. State handling is where containers get interesting. Unless a node is registered as ephemeral, the auth key is only needed on first run, and node state is stored in state_dir and reused when Caddy restarts. The README recommends using ephemeral with an always supplied auth key in a container, or mounting the state directory on a persistent volume, depending on the use case.
A typical Caddyfile configures Tailscale inside the global options block, so the tailscale option appears alongside other server wide settings:
{
tailscale {
# Tailscale auth key used to register nodes.
auth_key <auth_key>
# Alternate control server URL. Leave empty to use the default server.
control_url <control_url>
# If true, register ephemeral nodes that are removed after disconnect.
# Default: false
ephemeral true|false
}
}String options support the use of placeholders to populate values dynamically, such as from an environment variable, which is how you would inject an auth key without writing it into a file. The named node form adds its own keys, including hostname, tags, and a port setting used for tsnet, where leaving the port unset means tsnet picks a random available port. For Caddy JSON config, the plugin is registered as the tailscale app under apps, with fields from tscaddy.App.
Running the examples and the prebuilt container image
The example configurations expect an auth key in the TS_AUTHKEY environment variable, and they are run by pointing the caddy binary at a file from the examples directory:
TS_AUTHKEY=<tskey-auth-XXXXX> ./caddy run -c examples/<file>All nodes registered while running the examples are ephemeral and removed after disconnect, and the individual files carry comments with the details for each case. The container path is equally direct, using the prebuilt image from GitHub container registry:
docker run -it --rm ghcr.io/tailscale/caddy-tailscaleTo use your own configuration you mount a Caddyfile to /etc/caddy/Caddyfile and optionally mount a volume to /config to persist the default Tailscale state directory. The Dockerfile in the repository builds this image from a Go build stage into an alpine:3.21 base, copies the built caddy binary to /usr/bin/caddy, installs examples/simple.caddyfile as the default Caddyfile, sets XDG_CONFIG_HOME to /config and XDG_DATA_HOME to /data, and exposes ports 80, 443, 443 over UDP, and 2019 for the admin API.
The honest alternative is a plain Tailscale install on the host
The README spends a paragraph on why you might not need this plugin at all, and that paragraph is the most important thing in it. With Tailscale installed on a machine, Caddy can already bind to the Tailscale network interface, proxy requests to other tailnet nodes, get automatic certificates, and authenticate Tailscale users, using Caddy's own forward_auth support. That path is the supported one, it has no experimental label, and it needs no custom build of the Caddy binary. The plugin earns its place in two narrower situations that the README names: installing Tailscale on a machine is inconvenient, and everything should be self contained in the single caddy binary, or you want to serve multiple sites each connected as a separate Tailnet node. Outside those two cases, you are running experimental code to obtain a capability the standard combination already has.
Apache-2.0 licensed, no releases, and a moving dependency floor
The project is licensed under Apache-2.0, the permissive licence that allows commercial use, modification, and redistribution provided the notice and licence terms are kept, and the repository carries a LICENSE file at the top level. Maintenance signals are mixed and worth weighing. The last push to the repository was on 2026-08-26, so the code is moving. The project has no GitHub releases, so there is no tagged artifact to pin and no changelog to read for upgrade notes, which means the dependency versions in go.mod are your real source of truth. That module requires tailscale.com at v1.102.2, caddy v2.10.2, certmagic v0.24.0, and tscertificate helpers, so a Caddy upgrade can pull a new plugin build whether you wanted one or not. The repository also carries its own test files, app_test.go and module_test.go, and a .github directory, which is the shape you would expect from a project that intends to keep moving while its release story stays informal.
Editorial conclusion
Adopt caddy-tailscale when installing the Tailscale client on a host is genuinely inconvenient, when you need several sites each acting as a separate tailnet node, or when you want a single self contained binary to ship. Do not adopt it for a machine where the standard client installs cleanly, because the README states the plugin is still very experimental and the supported path there is a plain Tailscale install with Caddy binding to the Tailscale interface. Before you build, check the Caddy version your configuration depends on, because the documented build pins v2.9.1 while the module requires caddy v2.10.2, and decide whether you want ephemeral nodes, since the README warns that non ephemeral node state must be persisted across restarts.
Frequently asked questions
What does the caddy-tailscale plugin do?
It runs a Tailscale node inside the Caddy web server so that Caddy can join your Tailscale network without a separate Tailscale client. It provides a network listener, a proxy transport, an authentication provider, and a subcommand.
How do I build Caddy with the Tailscale plugin?
The README documents xcaddy build v2.9.1 --with github.com/tailscale/caddy-tailscale, or building the included cmd/caddy package with go build ./cmd/caddy, which is what the repository Dockerfile does.
Why does my Tailscale node not appear in my tailnet?
The tailscale global option only defines configuration values. Nodes are not registered and connected until they are used, such as listening on the node's interface or being used as a proxy transport.
Do I need an auth key to run caddy-tailscale?
All configuration values are optional, though an auth key is strongly recommended. Without one in the config it is loaded from the TS_AUTHKEY environment variable, and failing that the plugin logs an auth URL to the Caddy log.
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/tailscale-caddy-tailscale)Was this useful?