Skip to main content

Run a headscale control server

headscale is an open-source implementation of the Tailscale control server — the coordination plane your Tailscale clients log in to, exchange keys through, and get their network map from. Self-hosting it means your tailnet's coordination belongs to you.

By the end you'll have headscale answering over HTTPS at a hostname you own, its database and keys on a disk that survives redeploys, and a first node joined to your tailnet.

This is an application recipe, not a language guide

For getting your own source code onto Miren, start with Deployment and the Language Guides. This page is about self-hosting a prebuilt third-party server. For the opposite topic — running a Miren cluster on a tailnet — see Running Miren on a Tailnet.

Prerequisites

  • miren CLI installed and authenticated (miren whoami).
  • Access to the target cluster and its org.
  • A hostname you control, pointed at the cluster — see Custom Domains or claim one through Miren Cloud subdomains.
  • The ability to edit the cluster's server config. Tailscale clients hold a connection to the control server open far longer than the ingress allows by default, so this recipe needs one cluster-wide setting changed before a client will stay connected. Details in Give clients a longer timeout.
That timeout is cluster-wide

http_request_timeout applies to every app on the cluster, not just headscale. If you can't change it — someone else runs the cluster, or other apps depend on the current value — nodes will keep dropping their connection to headscale, and there's no way to work around it from headscale's side.

Select the target cluster

# Add a cluster your cloud identity can see (interactive picker; pins the TLS fingerprint)
miren cluster add

miren whoami

The Dockerfile

You only need to add your config file and tell the image what to run:

FROM docker.io/headscale/headscale:0.29.3

COPY config.yaml /etc/headscale/config.yaml

# The image sets an ENTRYPOINT but no CMD, so give it one.
CMD ["serve"]

That's the whole build. The upstream image already carries the CA bundle headscale needs to fetch the DERP map, so there's nothing to install.

Don't set a command for this app

The headscale image ships without a shell, and a service that sets command needs one. Leave it out — as the app.toml below does — and the image runs what its own CMD says. This also means you can't open an interactive shell in the container; see Getting a shell if you want that.

Add a .dockerignore so local files stay out of the build context:

.env
.miren

The config.yaml

This sits next to the Dockerfile and gets baked into the image. Everything that varies per deployment is overridden by an environment variable, so the file itself is the same for everyone:

# Overridden by HEADSCALE_SERVER_URL from app.toml.
server_url: http://127.0.0.1:8080

# Bind on all interfaces. A 127.0.0.1 listener is unreachable from outside the
# container, so the app never comes up healthy.
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090

# State on the mounted disk. headscale creates both on first start.
noise:
private_key_path: /data/noise_private.key
database:
type: sqlite
sqlite:
path: /data/db.sqlite

prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48

# Relay through Tailscale's public DERP servers.
derp:
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h

dns:
magic_dns: true
# Your server_url host must not sit underneath this domain.
base_domain: ts.example.com
override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 8.8.8.8

Any of these can move into app.toml as an environment variable instead: headscale accepts HEADSCALE_* overrides for nested keys too, so database.sqlite.path becomes HEADSCALE_DATABASE_SQLITE_PATH.

A missing dns block fails validation

dns.override_local_dns defaults to true, and headscale refuses to start without nameservers to go with it: Fatal config error: dns.nameservers.global must be set when dns.override_local_dns is true. Either set override_local_dns: false as above, or supply dns.nameservers.global.

The app.toml

This file lives at .miren/app.toml.

name = "headscale"

[build]
dockerfile = "Dockerfile"

# Must be named `web` — that's the service a hostname route points at.
# No `command` here, on purpose: see above.
[services.web]
port_timeout = "120s"

[services.web.concurrency]
mode = "fixed"
num_instances = 1

[[services.web.ports]]
port = 8080
name = "http"
type = "http"

# The database, the noise key, and (if you enable it) the DERP key.
[[services.web.disks]]
name = "state"
provider = "local"
mount_path = "/data"

# The public URL clients log in to. This must match the routed hostname exactly.
[[env]]
key = "HEADSCALE_SERVER_URL"
value = "https://headscale.example.com"

A control server shouldn't scale to zero or run two copies against one SQLite file, hence a single fixed instance. Persistent Storage recommends a local disk for SQLite, which is what this uses; note that any disk pins the app to the coordinator node.

Deploy

# Validate the config without building
miren deploy --analyze -a headscale

miren deploy -a headscale -f

Add a hostname route

The route target must match HEADSCALE_SERVER_URL exactly — clients are handed that URL and will use it for every subsequent request.

miren route set headscale.example.com headscale
miren route list

Give clients a longer timeout

Do this before you connect a client. Tailscale clients keep a connection to the control server open and idle for long stretches, longer than the ingress tolerates by default, and headscale offers no setting to make them chattier. Left alone, nodes drop and reconnect for no visible reason.

Raise the limit in the server config file (/etc/miren/server.toml, or /var/lib/miren/config/server.toml) and restart the server:

[server]
http_request_timeout = 120

The value is seconds. See Server Configuration.

Verify

miren app status -a headscale   # Current Version + active
miren sandbox list # a running sandbox for headscale, service "web"

curl -fsS https://headscale.example.com/health # {"status":"pass"}
One warning in the logs is expected

miren logs will show WRN listening without TLS but ServerURL does not start with http://. That's correct here: TLS terminates at Miren's ingress and headscale itself serves plain HTTP behind it, while server_url is properly https://. Nothing is wrong.

Create a user and register a node

The headscale command only talks to its own running server, so these have to run inside the live container. Find the sandbox in miren sandbox list — the one whose app is headscale and service is web — and use its ID:

miren sandbox list                              # find the headscale web sandbox

miren sandbox exec <id> -- headscale users create alice
miren sandbox exec <id> -- headscale users list # note alice's numeric ID
miren sandbox exec <id> -- headscale preauthkeys create --user <user-id> --expiration 24h
miren sandbox exec <id> -- headscale nodes list

preauthkeys create takes the user's numeric ID, not the name, which is why you look it up first. On a cluster running other apps, miren sandbox list will show their sandboxes too — match on the app name rather than taking the first row.

miren app run won't work here

It starts a fresh, separate container rather than reaching the one serving traffic, so the headscale command inside it has no server to talk to. Use miren sandbox exec.

Then, on the machine joining the tailnet, point Tailscale at your server and use the preauth key:

tailscale up --login-server=https://headscale.example.com --authkey=<key>

Getting a shell

If you'd rather be able to poke around inside the container, build on a base that has a shell instead. headscale's binary is statically linked, so it lifts out cleanly:

FROM docker.io/headscale/headscale:0.29.3 AS upstream

FROM debian:bookworm-slim
# bookworm-slim ships no CA bundle, and headscale needs one for the DERP map.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*

COPY --from=upstream /ko-app/headscale /usr/local/bin/headscale
COPY config.yaml /etc/headscale/config.yaml

Now that there's a shell, set the command explicitly:

[services.web]
command = "exec headscale serve"
port_timeout = "120s"

Everything else in the recipe is unchanged, and miren sandbox exec <id> opens a prompt. The tradeoff is a larger image and a base you're responsible for patching.

Running the embedded DERP relay

Tailscale clients prefer direct peer-to-peer connections and fall back to a DERP relay when they can't get one. The config above borrows Tailscale's public relays, which is the simpler choice and costs you nothing to operate.

To relay through your own server instead, enable headscale's embedded DERP. The relay is served over HTTPS on your existing hostname; STUN additionally needs a UDP port open on the host, declared as a node port.

Add the STUN port alongside the HTTP one, and turn the relay on:

# A second port on the same service; the HTTP one above stays as it is.
[[services.web.ports]]
port = 3478
name = "stun"
type = "udp"
node_port = 3478

[[env]]
key = "HEADSCALE_DERP_SERVER_ENABLED"
value = "true"
[[env]]
key = "HEADSCALE_DERP_SERVER_REGION_ID"
value = "999"
[[env]]
key = "HEADSCALE_DERP_SERVER_REGION_CODE"
value = "miren"
[[env]]
key = "HEADSCALE_DERP_SERVER_REGION_NAME"
value = "Miren embedded DERP"
[[env]]
key = "HEADSCALE_DERP_SERVER_STUN_LISTEN_ADDR"
value = "0.0.0.0:3478"
[[env]]
key = "HEADSCALE_DERP_SERVER_PRIVATE_KEY_PATH"
value = "/data/derp_server_private.key"

On boot headscale logs stun server started at [::]:3478 and advertises the new region. Open 3478/udp in any cloud firewall in front of the cluster — node ports aren't opened for you, and Firewall covers the inbound rules.

Keep the public relays as a fallback

Relayed traffic reaches your hostname over the same HTTPS path as everything else, so a relayed session that goes quiet long enough hits the timeout from earlier. Leave Tailscale's servers in derp.urls alongside your own rather than removing them.

Next steps