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.
Build it
-
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.
-
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.
-
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 -
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 -
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 enableLeaving outbound alone is deliberate. This locks the front door. Package installs, container pulls, and outbound API calls keep working.
-
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 -
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 -
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.
-
Prove it joined, from another tailnet member, not from the machine itself:
tailscale ping <new-host>andtailscale status | grep <new-host>. -
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 -
Reboot the machine and check both again. This is not optional. See gotcha 1.
-
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
-
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.
-
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.
-
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.
-
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.
-
tailscale logoutwill 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. -
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.
-
A tagged machine leaves
autogroup:self. If your SSH policy rules are scoped toautogroup: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
- 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.
- Give different machine classes different tags at birth, then express your entire access policy in terms of those tags rather than individual machines.
- 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.
- Consider ephemeral auth keys for machines that are genuinely disposable, so that the tailnet cleans up after them automatically instead of accumulating dead entries.