Skip to content

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.

An authorized developer’s request follows this path:

  1. Tailscale split DNS sends queries for the application domains to the internal DNS service at 100.74.53.55.
  2. Technitium DNS Server overrides the application, API, and WebSocket names with the server’s Tailscale IP.
  3. Tailscale grants allow only approved groups to reach the tagged server on TCP ports 22, 80, and 443.
  4. 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.

  • Tailscale administrator access
  • Technitium DNS Server administrator access through the dns-admin Tailscale service
  • sudo and 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. Tailscale requires outbound TCP 443; outbound UDP 3478 supports STUN, and outbound UDP from source port 41641 to arbitrary destination ports improves direct connections. Without a direct path, Tailscale falls back to DERP relays over TCP 443.
  • A connected Tailscale client for validation

The shared tailnet setup must already grant:

  • human members access to tag:dns on TCP and UDP port 53
  • tag:ci access to tag:dns on TCP and UDP port 53
  • Tailscale administrators access to tag:dns-admin on TCP port 80

Choose one stable, lowercase <SLUG>. Use only letters, numbers, and hyphens, for example foo.

ResourceNameDescription
Groupgroup:<SLUG>Human members with this group will be able to access the server
Server tagtag:<SLUG>-serverThe server node will be tagged with this identity
CI tagtag:<SLUG>-ciThe CI job will be tagged with this identity to access the server
Server hostname<SLUG>-prodThe server’s Tailscale hostname, used in the tailscale up command and visible in the admin console
Common server tagtag:serverAll servers share this tag for the common main-dev grant

Use the same slug in policy, Tailscale node names and CI configuration.

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.

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.

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:server and tag:<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.

Run this on the server. Enter the auth key only at the hidden prompt:

Terminal window
curl -fsSL https://tailscale.com/install.sh | sh
read -rsp "Tailscale auth key: " TS_AUTHKEY
echo
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:

Terminal window
tailscale status
tailscale ip -4

We 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:server and tag:<SLUG>-server
  • key expiry is disabled for this persistent server

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:

HostnameRecord nameTypeAddress
app.foo.de@A<SERVER_TAILSCALE_IP>
api.app.foo.deapiA<SERVER_TAILSCALE_IP>
ws.app.foo.dewsA<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:

Terminal window
dig @100.74.53.55 <APP_DOMAIN> A
dig @100.74.53.55 <API_DOMAIN> A
dig @100.74.53.55 <WS_DOMAIN> A

Each 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.

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.de
api.app.foo.de
ws.app.foo.de

use app.foo.de. One restricted suffix covers the name itself and its subdomains.

For sibling hostnames such as:

app.foo.de
api.foo.de
ws.foo.de

either 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!

Test through the operating system resolver, not only by querying the DNS server directly.

On Linux:

Terminal window
getent ahostsv4 <APP_DOMAIN>
getent ahostsv4 <API_DOMAIN>
getent ahostsv4 <WS_DOMAIN>

On macOS:

Terminal window
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:

Terminal window
curl -I http://<APP_DOMAIN>
curl -I https://<APP_DOMAIN>
curl -I https://<API_DOMAIN>

Validate SSH with the existing OpenSSH identity:

Terminal window
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

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 getent or dscacheutil; applications do not necessarily behave like a direct dig query.
  • 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 A records point to the same current Tailscale IP.
  • Check nginx server_name entries 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 443 is 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.

Perform removal in this order to stop new traffic before deleting the node:

  1. Remove the restricted split-DNS suffixes from Tailscale DNS.
  2. Remove the customer-specific A records and remove their Technitium Conditional Forwarder Zones when no other record uses them.
  3. Remove the specific grant, group, and tag owners from the Tailscale policy when no other deployment uses them.
  4. Disable or delete <HOSTNAME> in the Tailscale admin console.
  5. On the server, run sudo tailscale logout and uninstall Tailscale if it is no longer needed.
  6. 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.

The pipeline uses Tailscale userspace networking inside each job container. Each customer/tag scope 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.

Continue with Virtual Server over Tailscale to create the scoped OAuth client, configure the GitLab variables, and enable the Tailscale pipeline variant.