Recipe 01 · advanced · 9 min

Make every new VM join the tailnet before you ever log in

New machines arrive already on the tailnet, already firewalled, with no manual step and no window of exposure.

Position in the recipes track 01 Make ev. 02 Stop ty. 03 Receive. 04 Replace. 05 Put you. 06 Give a . 07 Put a t.

What you get

A brand new virtual machine that is already a tailnet member the first time you can reach it, with a firewall that accepts inbound traffic only from the tailnet. You never SSH to a public address, never paste an auth key by hand, and never have a window where a fresh box is sitting on the internet with a default configuration.

The pattern generalizes to any provider that runs a script on first boot: cloud-init user data, an image bake step, a provider level default setup script, a container entrypoint. What matters is not the provider mechanism. What matters is the order of operations and one specific way this setup lies to you after a reboot, covered in the gotchas.

How it works

Three things have to happen in a strict order, and the order is the whole recipe.

First the machine joins the tailnet, using a pre-authorized key so no human has to approve anything. Second, and only after the join is confirmed, the firewall closes to everything except the tailnet interface. Third, the rules are made to re-apply on every subsequent boot, because the thing that enables them at install time is not necessarily the thing that survives a restart.

Inverting the first two steps is how people lock themselves out. If you close the firewall before the machine is on the tailnet, and the join then fails, you have created a box with no ingress path at all.

The order of operations on first boot 1 install and join, tagged key 2 verify joined the gate for step 3 3 close ingress tailnet interface only 4 persist own boot unit join failed: STOP leave ingress open, do not orphan the box why this cannot lock you out the provider console arrives on loopback, and a host firewall always permits loopback, so the out of band path survives every rule you add. verify that claim on YOUR provider before you trust it, on a throwaway machine.

Build it

  1. Mint a tagged auth key. Create it in the admin console or through the API as reusable, tagged with the role the machine will have, and pre-approved if your tailnet requires device approval. Tagging matters for a second reason covered in the gotchas: a device that receives a tag has its key expiry disabled by default, so a long lived server does not silently fall off the tailnet in six months.

  2. Put the key somewhere the script can read, and nowhere else. It goes into the provisioning system as a secret, injected at render time. It never lands in the repository that holds the script. Treat a reusable tagged auth key as a credential that can mint tailnet members, because that is exactly what it is.

  3. Install and join, in the first boot script.

    curl -fsSL https://tailscale.com/install.sh | sh
    tailscale up \
      --authkey="${TAILSCALE_AUTHKEY:?authkey required}" \
      --hostname="$(hostname -s)" \
      --ssh
  4. Gate everything that follows on the join actually succeeding. Do not assume. Ask:

    if ! tailscale status --json | grep -q '"BackendState": *"Running"'; then
      echo "tailscale did not reach Running; leaving ingress open" >&2
      exit 0
    fi
  5. Close ingress to the tailnet interface only. Inbound denied by default, outbound untouched, the tailnet interface allowed, and the port Tailscale uses for direct connections allowed so peers can still reach you directly rather than being forced onto a relay.

    ufw --force reset
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow in on tailscale0
    ufw allow 41641/udp
    ufw --force enable

    Leaving outbound alone is deliberate. This locks the front door. Package installs, container pulls, and outbound API calls keep working.

  6. Make the rules re-apply on every boot with a unit you own. This step looks redundant. It is not, and the gotchas explain why in detail.

    [Unit]
    Description=Re-assert host firewall rules at boot
    After=network-online.target tailscaled.service
    
    [Service]
    Type=oneshot
    ExecStart=/usr/local/sbin/vm-firewall
    RemainAfterExit=yes
    
    [Install]
    WantedBy=multi-user.target
  7. Make the whole script exactly once. Many providers re-run the provisioning script on every restart, not just at creation. Guard it:

    [ -e /etc/vm-provisioned ] && exit 0
    # ... everything above ...
    touch /etc/vm-provisioned
  8. Auto approve the machine’s routes if it advertises any, using autoApprovers in the policy file, so a subnet router or exit node born this way is useful immediately instead of waiting for a human to approve its routes.

Verify it

Verification is the part people skip, and it is the part that matters, because the most dangerous outcome here is a machine that reports healthy and is not protected.

  1. Prove it joined, from another tailnet member, not from the machine itself: tailscale ping <new-host> and tailscale status | grep <new-host>.

  2. Prove the firewall is loaded, which is not the same as enabled. Read the actual packet filter rules, not the tool’s own status summary.

    sudo iptables -S | head
    sudo ufw status verbose
  3. Reboot the machine and check both again. This is not optional. See gotcha 1.

  4. Prove the public path is closed from off the tailnet entirely, ideally from a network that has nothing to do with your setup. A closed port from a machine that is on the tailnet proves nothing.

Gotchas

  1. Enabling a firewall is not the same as it surviving a reboot, and it will lie to you about the difference. On one hosting platform, enabling the firewall at install time worked perfectly and did not survive a restart: the service enablement was stripped across the reboot, so afterwards the configuration file still declared the firewall enabled while the service manager reported it disabled and the kernel had no rules loaded at all. Every configuration level check passed. The machine was open. The fix is step 6: a unit you create, which does persist, that re-asserts the rules on every boot. The test that catches it is a reboot followed by an assertion on loaded rules, and it belongs in your provisioning test suite as a first class case.

  2. Order matters, and the failure path matters more. Close ingress only after the tailnet join is confirmed. If the join fails and you have already closed the door, the machine is unreachable by every path you built. Failing open here is the correct choice: a machine that is briefly reachable is recoverable, a machine that is unreachable may not be.

  3. Know your out of band path before you need it. The reason this is safe on some platforms is that the provider console arrives over loopback, which a host firewall always permits, so it survives any ingress rule you write. That is a property of a specific platform, verified on a throwaway instance, not a law of nature. If your provider’s console is a normal inbound SSH connection, these rules will lock you out of it. Test on a machine you are willing to lose.

  4. Provisioning scripts often re-run on restart. Without an exactly once guard you get user creation, key installs, and firewall resets repeating on every boot, which is at best noisy and at worst destructive.

  5. tailscale logout will sever the session you are running it in. If a provisioning or teardown script logs out, run it detached, or you will kill your own connection partway through and leave the machine half configured.

  6. Watch the size limit on provider hosted scripts. Some platforms cap the setup script. Strip comments at render time rather than deleting documentation from the source, so the repository stays readable and the deployed artifact stays small.

  7. A tagged machine leaves autogroup:self. If your SSH policy rules are scoped to autogroup:self, tagging a machine silently removes SSH access to it, because tagging replaces user identity with tag identity. Pair every tag decision with the policy rule that grants access to that tag, in the same change.

Where to take it next

  1. Make the provisioning suite assert the post reboot state, not just the post install state. Any check that cannot survive a restart is not a check.
  2. Give different machine classes different tags at birth, then express your entire access policy in terms of those tags rather than individual machines.
  3. Have the machine advertise routes or exit node capability at birth and pair it with autoApprovers, so a new site router is useful the moment it exists.
  4. Consider ephemeral auth keys for machines that are genuinely disposable, so that the tailnet cleans up after them automatically instead of accumulating dead entries.

Sources

  1. Auth keys checked 2026-08-11
  2. Tags checked 2026-08-11
  3. Install Tailscale on Linux checked 2026-08-11
  4. What firewall ports should I open to use Tailscale? checked 2026-08-11
  5. Tailnet policy file syntax checked 2026-08-11
  6. Tailscale CLI checked 2026-08-11

All recipes