Server Access with Tailscale
This runbook connects an existing server to the company Tailscale tailnet. Authorized developers can then use the application’s normal hostnames and SSH without installing a specific VPN.
Tailscale provides only the network path. OpenSSH continues to authenticate users with the server’s existing SSH keys, and nginx continues to select the application from the hostname and TLS SNI.
Architecture
Section titled “Architecture”An authorized developer’s request follows this path:
- Tailscale split DNS sends queries for the application domains to the internal DNS service at
100.74.53.55. - Technitium DNS Server overrides the application, API, and WebSocket names with the server’s Tailscale IP.
- Tailscale grants allow only approved groups to reach the tagged server on TCP ports
22,80, and443. - nginx receives the original hostname and handles TLS, SPA, API, and WebSocket traffic normally.
DNS resolution does not grant network access! A tailnet member may resolve a hostname but still cannot connect unless a grant permits access to that server’s tags.
Prerequisites
Section titled “Prerequisites”- Tailscale administrator access
- Technitium DNS Server administrator access through the
dns-adminTailscale service sudoand existing SSH access to the server- The final application, API, and WebSocket hostnames
- A list of users who require specific access
- A server whose firewall permits SSH, HTTP, and HTTPS over its Tailscale interface (should be a given, no inbound rules are required, only 80, 443, 41641 (default traffic gateway) and 3478 (STUN queries) have to be allowed for outbound traffic)
- A connected Tailscale client for validation
The shared tailnet setup must already grant:
- human members access to
tag:dnson TCP and UDP port53 tag:ciaccess totag:dnson TCP and UDP port53- Tailscale administrators access to
tag:dns-adminon TCP port80
Naming convention
Section titled “Naming convention”Choose one stable, lowercase <SLUG>. Use only letters, numbers, and hyphens, for example foo.
| Resource | Name | Description |
|---|---|---|
| Group | group:<SLUG> | Human members with this group will be able to access the server |
| Server tag | tag:<SLUG>-server | The server node will be tagged with this identity |
| CI tag | tag:<SLUG>-ci | The CI job will be tagged with this identity to access the server |
| Server hostname | <SLUG>-prod | The server’s Tailscale hostname, used in the tailscale up command and visible in the admin console |
| Common server tag | tag:server | All servers share this tag for the common main-dev grant |
Use the same slug in policy, Tailscale node names and CI configuration.
Add a server
Section titled “Add a server”1. Collect the values
Section titled “1. Collect the values”Record these values before changing the tailnet:
Slug: <SLUG>Application domain: <APP_DOMAIN>API domain: <API_DOMAIN>WebSocket domain: <WS_DOMAIN>Authorized user: <AUTHORIZED_USER>For example, <SLUG> may be foo and the three domains may be app.foo.de, api.app.foo.de, and ws.app.foo.de.
2. Update the Tailscale policy
Section titled “2. Update the Tailscale policy”Open the tailnet access controls and merge the following entries into the existing policy.
Do not just replace existing groups, tagOwners, or grants sections!
Add the group:
"groups": { "group:<SLUG>": [ "<AUTHORIZED_USER>" ]}Add owners for the specific tags. The common tag:server owner should already exist; if it does not, add it as well.
"tagOwners": { "tag:server": ["autogroup:admin"], "tag:<SLUG>-server": ["autogroup:admin"], "tag:<SLUG>-ci": ["autogroup:admin"]}Add the specific human and CI grants:
"grants": [ { "src": ["group:<SLUG>"], "dst": ["tag:<SLUG>-server"], "ip": ["tcp:22", "tcp:80", "tcp:443"] }, { "src": ["tag:<SLUG>-ci"], "dst": ["tag:<SLUG>-server"], "ip": ["tcp:22"] }]The CI grant is intentionally limited to SSH.
Do not grant tag:<SLUG>-ci access to HTTP, HTTPS, or other servers.
3. Create the server auth key
Section titled “3. Create the server auth key”In the Tailscale admin console, open Settings > Keys and generate an auth key with these properties:
- Description:
<HOSTNAME> - Reusable: disabled
- Ephemeral: disabled
- Pre-approved: enabled
- Tags:
tag:serverandtag:<SLUG>-server
The key is a one-time enrollment secret for a persistent tagged node. Store it only long enough to register the server. Do not paste it into chat, documentation, shell history, or elsewhere.
4. Install and register Tailscale
Section titled “4. Install and register Tailscale”Run this on the server. Enter the auth key only at the hidden prompt:
curl -fsSL https://tailscale.com/install.sh | sh
read -rsp "Tailscale auth key: " TS_AUTHKEYecho
sudo tailscale up \ --auth-key="$TS_AUTHKEY" \ --hostname="<HOSTNAME>" \ --accept-dns=false
unset TS_AUTHKEY--accept-dns=false is required.
It prevents split DNS from replacing the server’s own DNS configuration.
Confirm the node is connected and note its IPv4 address:
tailscale statustailscale ip -4We will use this result as <SERVER_TAILSCALE_IP> in the next steps!
It will be in a format like 100.*.*.*.
In the admin console, verify:
- hostname:
<HOSTNAME> - tags:
tag:serverandtag:<SLUG>-server - key expiry is disabled for this persistent server
5. Add the Technitium DNS overrides
Section titled “5. Add the Technitium DNS overrides”Open the Technitium DNS Server web console through the restricted dns-admin service.
Under Zones, select Add Zone and create a zone with these properties:
- Zone: the same narrow suffix that will be configured for split DNS in the next step
- Type: Conditional Forwarder Zone
- Protocol and forwarder: the approved upstream resolver used for public DNS
Use a Conditional Forwarder Zone rather than a Primary Zone. This allows local records to override the required application names while names without a local record continue to resolve through the configured upstream resolver.
Edit the new zone and add an A record for each application hostname. Set each record’s address to <SERVER_TAILSCALE_IP>.
The record names are relative to the zone. For a zone named app.foo.de, configure:
| Hostname | Record name | Type | Address |
|---|---|---|---|
app.foo.de | @ | A | <SERVER_TAILSCALE_IP> |
api.app.foo.de | api | A | <SERVER_TAILSCALE_IP> |
ws.app.foo.de | ws | A | <SERVER_TAILSCALE_IP> |
For sibling hostnames covered by three exact zones, create one Conditional Forwarder Zone per hostname and add the A record at @ in each zone.
If a broader zone such as foo.de is acceptable, create one zone and use relative record names such as app, api, and ws instead.
Do not add wildcard records. Test an unrelated name covered by the zone and confirm that Technitium forwards it instead of returning <SERVER_TAILSCALE_IP>.
Test the DNS service directly from an authorized client:
dig @100.74.53.55 <APP_DOMAIN> Adig @100.74.53.55 <API_DOMAIN> Adig @100.74.53.55 <WS_DOMAIN> AEach application query must return <SERVER_TAILSCALE_IP>. You can also run these queries from the DNS Client tab in the Technitium web console with Server set to This Server.
6. Add split DNS
Section titled “6. Add split DNS”In the Tailscale admin console, open DNS > Nameservers and add 100.74.53.55 as a restricted nameserver for the narrowest practical suffix.
For nested hostnames such as:
app.foo.deapi.app.foo.dews.app.foo.deuse app.foo.de. One restricted suffix covers the name itself and its subdomains.
For sibling hostnames such as:
app.foo.deapi.foo.dews.foo.deeither add three narrow restricted suffixes or use foo.de only when it is acceptable for us to resolve the complete zone.
Prefer narrow suffixes so unrelated names continue using public DNS!
Do not enable the tailnet-wide DNS override for this setup!
7. Validate access
Section titled “7. Validate access”Test through the operating system resolver, not only by querying the DNS server directly.
On Linux:
getent ahostsv4 <APP_DOMAIN>getent ahostsv4 <API_DOMAIN>getent ahostsv4 <WS_DOMAIN>On macOS:
dscacheutil -q host -a name <APP_DOMAIN>dscacheutil -q host -a name <API_DOMAIN>dscacheutil -q host -a name <WS_DOMAIN>All three names must resolve to <SERVER_TAILSCALE_IP>.
If a client cached an old result, reconnect Tailscale or flush the operating system’s DNS cache before retrying.
Validate HTTP and TLS:
curl -I http://<APP_DOMAIN>curl -I https://<APP_DOMAIN>curl -I https://<API_DOMAIN>Validate SSH with the existing OpenSSH identity:
ssh brezel@<HOSTNAME>Repeat the connection test as a user who is in group:<SLUG> but not in group:main-dev.
This confirms that the specific group-to-server grant works independently of the common developer grant.
Run the server’s GitLab deployment job and confirm that its tag:ci and tag:<SLUG>-ci node can connect to tag:<SLUG>-server over SSH.
The CI node must not be able to connect to another server-specific tag.
Open https://<APP_DOMAIN> in a browser and check:
- the SPA loads
- login and session cookies work
- API requests succeed
- HTTP redirects to HTTPS
- the certificate is valid for the requested hostname
- the Reverb WebSocket connects and remains connected
Troubleshooting
Section titled “Troubleshooting”Direct DNS works, but system resolution does not
Section titled “Direct DNS works, but system resolution does not”- Confirm the Tailscale client is connected and accepts tailnet DNS.
- Confirm the restricted suffix exactly covers the requested hostname.
- Check the operating system resolver with
getentordscacheutil; applications do not necessarily behave like a directdigquery. - Flush stale DNS results or reconnect Tailscale.
DNS resolves, but the connection times out
Section titled “DNS resolves, but the connection times out”- Confirm the destination is the server’s current Tailscale IP.
- Confirm the node has both expected tags in the admin console.
- Confirm the user belongs to the expected policy group.
- Confirm the grant permits the required destination tag and TCP port.
- Check the server firewall and that nginx or SSH listens on the Tailscale interface.
HTTPS reaches the wrong site or has a certificate error
Section titled “HTTPS reaches the wrong site or has a certificate error”- Confirm the client used the real application hostname, not the Tailscale IP.
- Confirm all three Technitium
Arecords point to the same current Tailscale IP. - Check nginx
server_nameentries and TLS certificates for<APP_DOMAIN>,<API_DOMAIN>, and<WS_DOMAIN>.
The application loads, but API or WebSockets fail
Section titled “The application loads, but API or WebSockets fail”- Resolve and test
<API_DOMAIN>and<WS_DOMAIN>independently. - Confirm TCP
443is allowed by the Tailscale grant and server firewall. - Inspect browser developer tools for CORS, cookie, TLS, and WebSocket upgrade errors.
- Confirm nginx forwards the WebSocket upgrade to Laravel Reverb.
SSH reaches the server but authentication fails
Section titled “SSH reaches the server but authentication fails”Tailscale networking is working. Check the brezel user’s OpenSSH authorized_keys, the selected local identity, and the SSH daemon logs. Do not enable Tailscale SSH as a workaround.
Remove or roll back access
Section titled “Remove or roll back access”Perform removal in this order to stop new traffic before deleting the node:
- Remove the restricted split-DNS suffixes from Tailscale DNS.
- Remove the customer-specific
Arecords and remove their Technitium Conditional Forwarder Zones when no other record uses them. - Remove the specific grant, group, and tag owners from the Tailscale policy when no other deployment uses them.
- Disable or delete
<HOSTNAME>in the Tailscale admin console. - On the server, run
sudo tailscale logoutand uninstall Tailscale if it is no longer needed. - Confirm the application names use their expected public or network DNS answers again.
If only one user should lose access, remove that user from group:<SLUG> instead of removing the server.
GitLab CI access
Section titled “GitLab CI access”The pipeline uses Tailscale userspace networking inside each job container.
Each server uses a separately scoped OAuth client that can request only tag:ci and tag:<SLUG>-ci.
The required server-specific policy grant is:
{ "src": ["tag:<SLUG>-ci"], "dst": ["tag:<SLUG>-server"], "ip": ["tcp:22"]}The job’s tag:ci identity separately permits DNS access to tag:dns on TCP and UDP port 53.
Do not broaden the server grant beyond SSH and do not reuse one server’s OAuth client for another server.