A tsnet based service for managing custom domains in your Tailnet, along with libraries to enable your Tailscale nodes to manage DNS records, and retrieve public (Let's Encrypt) TLS certificates at runtime.
Running a tsdmg service in your Tailnet enables several use-cases not possible out-of-the-box with Tailscale:
- Custom domains for your Tailscale nodes e.g.
<node>.yourdomain.com - Allow Tailscale nodes to retrieve public (Let's Encrypt) TLS certificates for custom domains
- Allow Tailscale nodes to manage your domains/subdomains arbitrarily
My motivation to build this is that I wanted all my internal services and web applications (not on the Internet, accesible only via Tailscale) to be reachable via my custom domain (
<node>.services.adrianosela.com), and I wanted them to serve TLS/HTTPS using public (Let's Encrypt) certificates.Really I wish I could just delegate the
services.adrianosela.comzone to Tailscale, and have them do this for me (but it's not possible as of Feb 2026).
Essentially:
- Using Tailscale ACLs, you define which Tailscale sources (nodes, users, groups) can manage which subdomains (e.g. node "webapp" can manage "webapp.yourdomain.com")
- You provision the
tsdmgservice with credentials for your DNS provider (e.g. Cloudflare, Google, GoDaddy, etc...) - Your Tailscale nodes can request domains to be created/updated/deleted against the
tsdmgservice via HTTP - The
tsdmgservice will use incoming requests' Tailscale identity to authenticate and authorize (based on Tailscale ACLs) domain management requests
Run the tsdmg service as shown in ./cmd/server/main.go:
tsdmg, err := service.New(ctx, tsClient, dnsProvider)
if err != nil {
log.Fatalf("failed to initialize tsdmg service: %v", err)
}
if err = tsdmg.ServeHTTP(ln); err != nil {
log.Fatalf("failed to serve HTTP over tsnet listener: %v", err)
}
The dns.Provider interface is implemented for all major DNS providers by https://github.com/libdns e.g.:
- Cloudflare: https://github.com/libdns/cloudflare
- Google Cloud DNS: https://github.com/libdns/googleclouddns
- GoDaddy: https://github.com/libdns/godaddy
The tsdmg server is available as a Docker image:
docker run ghcr.io/adrianosela/tsdmg -hdocker run -it ghcr.io/adrianosela/tsdmg \
-domain=yourdomain.com \
-dns-provider=cloudflare \
-cloudflare-api-token=$TSDMG_CLOUDFLARE_API_TOKEN \
-ts-authkey=$TSDMG_TS_AUTHKEYSee the Makefile
tsdmg-dockertarget for a complete working example.
| Flag | Description | Required | Default |
|---|---|---|---|
-addr |
Address to listen on | No | :80 |
-hostname |
Hostname to use for Tailscale machine | No | tsdmg |
-ts-authkey |
Tailscale auth key | Yes | - |
-dns-provider |
Which DNS provider to use (one of aws, gcp, azure, cloudflare, godaddy) |
Yes | - |
-domain |
Domain management allowlist (repeatable) | No | - |
-node-reg-domain |
Domain in which to create A/AAAA records for registering Tailscale nodes (repeatable, must be subset of -domain if both set) |
No | - |
-cloudflare-api-token |
Cloudflare API Token | If dns-provider=cloudflare |
- |
-aws-access-key-id |
AWS Access Key ID | If dns-provider=aws (or use profile) |
- |
-aws-secret-access-key |
AWS Secret Access Key | If dns-provider=aws (or use profile) |
- |
-aws-profile |
AWS Profile | If dns-provider=aws (or use keys) |
- |
-gcp-project |
Google Cloud Project ID | If dns-provider=gcp |
- |
-gcp-svc-acct-json |
Google Cloud Service Account JSON | If dns-provider=gcp |
- |
-azure-subscription-id |
Azure Subscription ID | If dns-provider=azure |
- |
-azure-resource-group-name |
Azure Resource Group Name | If dns-provider=azure |
- |
-azure-client-id |
Azure Client ID | If dns-provider=azure |
- |
-azure-client-secret |
Azure Client Secret | If dns-provider=azure |
- |
-azure-tenant-id |
Azure Tenant ID | If dns-provider=azure |
- |
-godaddy-api-token |
GoDaddy API Token | If dns-provider=godaddy |
- |
This package includes a tsdmg client capable of requesting, caching, and refreshing public (Let's Encrypt) TLS certificates, by leveraging a tsdmg server.
serverURL := "http://tsdmg" // my server's node name is tsdmg
opts := []tsdmg.Option{
// My laptop is already running the Tailscale desktop
// client, so the tsdmg server is already reachable by
// node-name i.e. http://tsdmg. Not setting this option
// will attempt to initialize a new Tailscale node.
tsdmg.WithSkipTailscaleNode(true),
}
client, err := tsdmg.NewClient(ctx, serverURL, opts...)
if err != nil {
log.Fatalf("failed to initialize client: %v", err)
}
defer client.Close()
created, err := client.CreateRecords(ctx, recordsToCreate...)
// check error
deleted, err := client.DeleteRecords(ctx, recordsToDelete...)
// check error
With an initialized tsdmg.Client client:
NOTE: import "github.com/adrianosela/tsdmg/pkg/tsautocert"
certCommonName := "macbook.tsdmg.net"
opts := []tsautocert.Option{
// Cache certificates in the filesystem to
// avoid hitting the Let's Encrypt rate limit.
tsautocert.WithCertificateCache(autocert.DirCache("./certcache")),
}
certManager, err := tsautocert.NewCertificateManager(
ctx,
client,
certCommonName,
opts...,
)
if err != nil {
log.Fatalf("failed to initialize certificate manager: %v", err)
}
defer certManager.Close()
if err := certManager.WaitForInitialCert(ctx); err != nil {
log.Fatalf("failed to wait for initial certificate: %v", err)
}
ln, err := net.Listen("tcp", ":443")
if err != nil {
log.Fatalf("failed to start tcp listener: %v", err)
}
// Configure TLS listener to get certificate using tsdmg client.
ln = tls.NewListener(ln, &tls.Config{GetCertificate: certManager.GetCertificate})
defer ln.Close()
err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}))
if err != nil {
log.Fatalf("failed to serve HTTP: %v", err)
}
To allow ANY node to retrieve TLS certificates for <node>.<your-custom-domain> (e.g. your-macbook.yourdomain.com),
you can add a grant in your ACL as follows:
The
${node}will be replaced with thetsdmgclient node's name by thetsdmgserver prior to evaluation.
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["*"],
"app": {
"tsdmg.net/dns/v1": [
{
"TXT": ["_acme-challenge.${node}.yourdomain.com"],
},
],
},
},
],
Say you also want your client to have the ability to create A and AAAA records (e.g. for its own tailnet private IP or any other IP):
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["*"],
"app": {
"tsdmg.net/dns/v1": [
{
"TXT": ["_acme-challenge.${node}.yourdomain.com"],
"A": ["${node}.yourdomain.com"],
"AAAA": ["${node}.yourdomain.com"],
},
],
},
},
],
- Better project structure e.g.
internal, not everything aspkg