Course 06 — Tailscale Setup
Course 06 — Tailscale Setup
Section titled “Course 06 — Tailscale Setup”Goal: Install Tailscale on macOS, enable MagicDNS and HTTPS certificates in the admin console, expose the portal and its sessions securely over your tailnet, and implement the internal/tailscale Go module that wires this into the portal.
Prerequisite: Course 05 — Deployment. Tailscale is optional — the portal works without it — but this course unlocks HTTPS URLs for all sessions.
Output: The portal and all OpenCode/VS Code/script sessions accessible at https://<your-machine>.ts.net from any device on your tailnet. No port forwarding, no self-signed certificates.
Lesson 1 — What Tailscale Does and Why
Section titled “Lesson 1 — What Tailscale Does and Why”The core idea
Section titled “The core idea”Tailscale creates a private, encrypted mesh network (called a tailnet) between all your devices using WireGuard under the hood. Every device on the tailnet gets:
- A stable private IP in the
100.x.x.xrange (stays the same regardless of which Wi-Fi network you’re on) - A DNS name via MagicDNS:
<machine-name>.<tailnet-name>.ts.net - The option to serve local ports over valid HTTPS using
tailscale serve
For the workspace-portal, this means:
| Without Tailscale | With Tailscale |
|---|---|
http://localhost:4000 — only on the machine | https://my-mac.tail1234.ts.net — any device on the tailnet |
http://localhost:4101 — OpenCode session, local only | https://my-mac.tail1234.ts.net:4101 — OpenCode from phone |
| Self-signed cert or no TLS | Valid Let’s Encrypt cert, auto-provisioned |
| Code Server complains about insecure context | Code Server works: requires HTTPS for clipboard, PWA features |
Why tailscale serve specifically
Section titled “Why tailscale serve specifically”tailscale serve is Tailscale’s built-in reverse proxy. When you run:
tailscale serve --bg --https=443 http://localhost:4000Tailscale:
- Registers a DNS-01 challenge with Let’s Encrypt on your behalf
- Provisions a TLS certificate for
<machine>.ts.net(or<machine>.ts.net:443) - Terminates HTTPS on the Tailscale daemon and forwards plaintext to
localhost:4000 - Persists this configuration across reboots (the
--bgflag)
The portal shells out to the tailscale binary to call this for each session port — no SDK, no Tailscale API keys required.
Lesson 2 — Installing Tailscale on macOS
Section titled “Lesson 2 — Installing Tailscale on macOS”There are three install variants. They differ in what the tailscale CLI binary can do and where it lives.
Option A — Standalone .pkg (recommended)
Section titled “Option A — Standalone .pkg (recommended)”Download from pkgs.tailscale.com/stable/#macos. This is the variant Tailscale recommends for developer machines.
# After installing the .pkg, the CLI binary is at:/Applications/Tailscale.app/Contents/MacOS/Tailscale
# Tailscale adds a symlink automatically:which tailscale # → /usr/local/bin/tailscaleThe .pkg installs a menu-bar app that starts tailscaled automatically on login. This is what the launchd PATH in Course 05 expects: /usr/local/bin is on the default path.
Option B — Mac App Store
Section titled “Option B — Mac App Store”Works for day-to-day VPN use but has one critical limitation for the portal:
Mac App Store variant cannot serve files or directories due to the macOS App Sandbox.
tailscale serve /some/pathwill fail.tailscale serve http://localhost:PORTworks fine.
Since the portal only uses port-forwarding mode (http://localhost:PORT), the App Store variant is technically sufficient. But if you ever want to use tailscale serve for file serving outside the portal, use Option A.
Option C — Homebrew CLI only
Section titled “Option C — Homebrew CLI only”Use this if you want no menu-bar app and manage the daemon yourself (e.g. headless server, Docker host).
brew install tailscale
# Start the daemon (must run once; not started automatically by Homebrew):sudo tailscaled &
# Or install it as a launchd daemon (run once):sudo tailscaled install-system-daemonAfter install-system-daemon, tailscaled starts on boot as a system daemon (root). The tailscale CLI is at /opt/homebrew/bin/tailscale (Apple Silicon) or /usr/local/bin/tailscale (Intel).
Important for the portal’s launchd plist: The launchd
PATHin Course 05 includes/opt/homebrew/bin. If you used the standalone.pkg, the symlink at/usr/local/bin/tailscaleis also included. Either install path works.
Verify the install
Section titled “Verify the install”tailscale version# → 1.xx.xLesson 3 — Connecting to Your Tailnet
Section titled “Lesson 3 — Connecting to Your Tailnet”Sign in
Section titled “Sign in”tailscale up# Opens a browser window to authenticateIf the machine is headless (no browser):
tailscale up --qr# Prints a QR code to scan with your phoneCheck status
Section titled “Check status”tailscale status# my-mac 100.x.x.x macOS -# my-phone 100.x.x.y iOS -The machine name shown here becomes the subdomain in your HTTPS URL. If it is something ugly like tobias-macbook-pro-2023, rename it now before provisioning a TLS certificate — the certificate binds to the name and cannot be changed after provisioning.
Rename the machine (optional but recommended)
Section titled “Rename the machine (optional but recommended)”In the Tailscale admin console → Machines, find the machine, click … → Edit machine name. Choose something short and stable: dev-mac, homelab, workstation.
After renaming:
tailscale status# dev-mac 100.x.x.x macOS -Your HTTPS URL will be https://dev-mac.<tailnet>.ts.net.
Disable key expiry (recommended for always-on machines)
Section titled “Disable key expiry (recommended for always-on machines)”By default, Tailscale node keys expire after 90 days, requiring re-authentication. For a machine running the portal as a launchd service, key expiry breaks remote access silently.
In the admin console → Machines → select the machine → Disable key expiry.
Alternatively, if you use tags, key expiry is disabled by default on tagged nodes.
Lesson 4 — Enabling MagicDNS
Section titled “Lesson 4 — Enabling MagicDNS”MagicDNS registers DNS names for every device on your tailnet automatically. Without it, tailscale serve --https cannot provision certificates (it needs a DNS name to put on the cert).
Check if MagicDNS is already enabled
Section titled “Check if MagicDNS is already enabled”tailscale statusIf your machine shows a .ts.net hostname like dev-mac.tail1234.ts.net, MagicDNS is active. Tailnets created after October 2022 have it enabled by default.
If the output only shows IP addresses and no .ts.net name, you need to enable it.
Enable MagicDNS (admin console)
Section titled “Enable MagicDNS (admin console)”There is no CLI command to enable MagicDNS — it is a tailnet-wide setting in the admin console:
- Go to login.tailscale.com/admin/dns
- Under DNS, find the MagicDNS toggle
- Click Enable MagicDNS
- If prompted to add a nameserver, you can skip it — Tailscale v1.20+ does not require one
After enabling, verify:
tailscale status# dev-mac 100.x.x.x macOS dev-mac.tail1234.ts.netThe .ts.net FQDN now appears.
Lesson 5 — Enabling HTTPS Certificates
Section titled “Lesson 5 — Enabling HTTPS Certificates”HTTPS certificates let Tailscale provision a valid TLS cert for your machine’s MagicDNS name via Let’s Encrypt. This is what makes tailscale serve --https work without browser warnings.
Enable HTTPS in the admin console
Section titled “Enable HTTPS in the admin console”- Go to login.tailscale.com/admin/dns
- Under HTTPS Certificates, click Enable HTTPS
- Read the acknowledgement: your machine names will appear in the public Certificate Transparency ledger. The tailnet name (e.g.
tail1234.ts.net) is already public, but so will the machine names of any machine you runtailscale certon. If this is a concern, rename machines to non-identifying names before proceeding. - Confirm
Provision the certificate on your machine
Section titled “Provision the certificate on your machine”tailscale cert dev-mac.tail1234.ts.net# Wrote dev-mac.tail1234.ts.net.crt# Wrote dev-mac.tail1234.ts.net.keyThis uses a DNS-01 ACME challenge — Tailscale handles it automatically. The cert and key files are written to the current directory. For tailscale serve, you do not need to manage these files manually; tailscale serve --https provisions its own cert internally. The tailscale cert command is mainly used when you want the cert files for another process (Caddy, nginx, etc.).
Certificate renewal: Let’s Encrypt certs expire after 90 days.
tailscale servemanages renewal automatically. If you usedtailscale certto export files for another server, you are responsible for renewal — either re-runtailscale certbefore expiry, or use Caddy’s Tailscale integration which renews automatically.
Verify HTTPS is working
Section titled “Verify HTTPS is working”tailscale serve --bg --https=8080 http://localhost:8080# Serve started.# Available within your tailnet:# https://dev-mac.tail1234.ts.net:8080
tailscale serve status# https://dev-mac.tail1234.ts.net:8080 (tailnet only)# |-- / http://localhost:8080
# Clean up the testtailscale serve --https=8080 offIf tailscale serve --https fails with “HTTPS not available”, ensure the HTTPS toggle is on in the admin console and that MagicDNS is enabled.
Lesson 6 — Exposing the Portal Over Tailscale
Section titled “Lesson 6 — Exposing the Portal Over Tailscale”With MagicDNS and HTTPS enabled, exposing the portal is a single command.
Register the portal
Section titled “Register the portal”tailscale serve --bg --https=443 http://localhost:4000The portal is now accessible at https://dev-mac.tail1234.ts.net from any device on your tailnet.
tailscale serve status# https://dev-mac.tail1234.ts.net (tailnet only)# |-- / http://localhost:4000The --bg flag persists this across reboots and Tailscale restarts. If you restart the machine or restart tailscaled, tailscale serve automatically resumes.
Remove the portal registration
Section titled “Remove the portal registration”tailscale serve --https=443 offVerify from another device
Section titled “Verify from another device”On your phone or another machine on the tailnet:
https://dev-mac.tail1234.ts.netYou should see the portal UI with a valid HTTPS certificate, no browser warnings.
Lesson 7 — Tailscale in internal/config
Section titled “Lesson 7 — Tailscale in internal/config”Before implementing internal/tailscale, it is worth understanding how Tailscale is represented in the config module. The config structs were scaffolded in Course 02 so that config.go compiles before the Tailscale integration exists. This lesson explains those decisions in detail.
TSConfig — the config struct
Section titled “TSConfig — the config struct”type TSConfig struct { Enabled bool `yaml:"enabled"` Binary string `yaml:"binary"`}Enabled is false by default — opting in requires an explicit tailscale.enabled: true in config.yaml. This makes Tailscale strictly opt-in: a portal deployed without Tailscale never calls the tailscale binary.
Binary defaults to "tailscale" (resolved via PATH). Override it if the binary lives at a non-standard path (e.g. /opt/homebrew/bin/tailscale when using Homebrew on Apple Silicon without a PATH fix in the launchd plist).
Config struct field
Section titled “Config struct field”type Config struct { // ...other fields... Tailscale TSConfig `yaml:"tailscale"`}The field is present regardless of whether Tailscale is enabled. This is intentional: the TSConfig struct is always unmarshalled from YAML, so you can stage tailscale.enabled: false in your config file and flip it to true when ready, without any Go code changes.
Default in defaults()
Section titled “Default in defaults()”Tailscale: TSConfig{ Binary: "tailscale",},Only Binary gets a default. Enabled is left as the zero value (false) — requiring an explicit opt-in.
Env var override
Section titled “Env var override”if v := os.Getenv("PORTAL_TAILSCALE_ENABLED"); v == "true" { cfg.Tailscale.Enabled = true}This follows the same pattern as the other overrides: env vars take precedence over the YAML file. There is no PORTAL_TAILSCALE_ENABLED=false path because the zero value is already false — an env var can only enable Tailscale, not disable it (use the YAML for that).
Sample config.yaml with Tailscale enabled
Section titled “Sample config.yaml with Tailscale enabled”workspaces_root: ~/workspacesportal_port: 4000
tailscale: enabled: true binary: tailscale # or /usr/local/bin/tailscaleLesson 8 — internal/tailscale: The Go Module
Section titled “Lesson 8 — internal/tailscale: The Go Module”This lesson implements the Go module that the portal uses to register and deregister session ports with tailscale serve. This code was introduced in the module scaffold in Course 02 but deferred here.
Why shell out instead of using the Tailscale SDK
Section titled “Why shell out instead of using the Tailscale SDK”The Tailscale Go SDK exists but adds significant dependency weight and requires the portal to understand Tailscale’s internal state. Shelling out to the tailscale CLI is:
- Simpler — the binary is already installed and authenticated
- More loosely coupled — the portal doesn’t need to know anything about Tailscale’s internals
- Easier to test — a fake
tailscaleshell script is a complete stub
internal/tailscale/serve.go
Section titled “internal/tailscale/serve.go”package tailscale
import ( "fmt" "os/exec" "strconv")
// Serve implements session.Registrar using the tailscale CLI.type Serve struct { Binary string // path to the tailscale binary, e.g. "tailscale" or "/usr/local/bin/tailscale"}
// Register runs: tailscale serve --bg --https={port} http://localhost:{port}// The returned URL is empty — the caller constructs it from the machine's FQDN.func (s *Serve) Register(port int) (string, error) { p := strconv.Itoa(port) cmd := exec.Command(s.Binary, "serve", "--bg", "--https="+p, "http://localhost:"+p, ) if out, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("tailscale serve: %w\n%s", err, out) } // URL construction is the caller's responsibility — it knows the machine FQDN. return "", nil}
// Deregister removes the serve config for the given port.// Uses best-effort: if the port was already deregistered, this is a no-op.func (s *Serve) Deregister(port int) error { p := strconv.Itoa(port) cmd := exec.Command(s.Binary, "serve", "--https="+p, "off") cmd.Run() // intentionally best-effort return nil}Wiring in internal/server/server.go
Section titled “Wiring in internal/server/server.go”In Start(), after loading the config, build the registrar:
import ( // ... "workspace-portal/internal/tailscale")
func Start(cfg *config.Config) error { var registrar session.Registrar if cfg.Tailscale.Enabled { registrar = &tailscale.Serve{Binary: cfg.Tailscale.Binary} } else { registrar = &session.NoopRegistrar{} } // pass registrar to the session manager...}When tailscale.enabled: false in config.yaml, NoopRegistrar is used — Register and Deregister are no-ops. Sessions are still assigned ports and started; the session URL in the UI is http://localhost:{port} instead of an HTTPS tailnet URL.
How the session manager uses the registrar
Section titled “How the session manager uses the registrar”In internal/session/manager.go, after a session becomes healthy:
// After health check passes:url, err := m.registrar.Register(sess.Port)if err != nil { log.Printf("tailscale register port %d: %v", sess.Port, err) // Non-fatal — session is still usable at localhost} else if url != "" { sess.URL = url}// If url is empty (tailscale registered but didn't return a URL), construct it:if sess.URL == "" && m.cfg.Tailscale.Enabled { status, _ := tailscaleStatus() // or store the FQDN at startup sess.URL = fmt.Sprintf("https://%s:%d", status.Self.FQDN, sess.Port)}Testing without Tailscale installed
Section titled “Testing without Tailscale installed”The Registrar interface means you can test the session manager with a mock:
type MockRegistrar struct { RegisteredPorts []int}
func (m *MockRegistrar) Register(port int) (string, error) { m.RegisteredPorts = append(m.RegisteredPorts, port) return fmt.Sprintf("https://mock.ts.net:%d", port), nil}
func (m *MockRegistrar) Deregister(port int) error { return nil}For integration tests of the internal/tailscale package itself, write a fake tailscale shell script to $PATH:
#!/usr/bin/env bashecho "https://fake-host.ts.net:$5"exit 0Then in the test, set PATH to include the directory containing the fake binary.
Lesson 9 — Troubleshooting
Section titled “Lesson 9 — Troubleshooting”tailscale serve status — check what’s registered
Section titled “tailscale serve status — check what’s registered”tailscale serve status# https://dev-mac.tail1234.ts.net (tailnet only)# |-- / http://localhost:4000# https://dev-mac.tail1234.ts.net:4101 (tailnet only)# |-- / http://localhost:4101This shows every active serve route. If the portal started a session but you don’t see the port here, tailscale.enabled is likely false in config.
tailscale serve reset — clear everything
Section titled “tailscale serve reset — clear everything”tailscale serve resetRemoves all serve routes. Use this if sessions accumulate stale routes after portal crashes.
The portal calls
Deregisteron cleanstop— but if the portal crashes mid-session, routes can leak.tailscale serve resetclears them all at once. Run it before restarting the portal after a crash.
”HTTPS not available” error
Section titled “”HTTPS not available” error”The tailscale serve --https command requires both:
- MagicDNS enabled (admin console → DNS page)
- HTTPS certificates enabled (same page)
Check both toggles.
Certificate errors in the browser
Section titled “Certificate errors in the browser”If https://dev-mac.tail1234.ts.net shows a certificate warning:
# Check the current cert expirytailscale cert dev-mac.tail1234.ts.net 2>&1# Or check via openssl:openssl s_client -connect dev-mac.tail1234.ts.net:443 </dev/null 2>/dev/null | openssl x509 -noout -datesIf the cert is expired, tailscale serve usually auto-renews. If it doesn’t, run tailscale serve reset && tailscale serve --bg --https=443 http://localhost:4000 to force re-registration.
Key expiry breaks remote access
Section titled “Key expiry breaks remote access”If the machine’s Tailscale key expires, all serve routes become unreachable — but the routes remain registered. The fix:
tailscale up # re-authenticateTo avoid this: disable key expiry in the admin console for the portal machine (see Lesson 3).
Port conflicts between tailscale serve routes
Section titled “Port conflicts between tailscale serve routes”Each port can only have one serve target. If the portal assigns port 4101 to a session and tailscale serve already has a route for :4101 from a previous (crashed) session, Register will fail with an error like “already in use”.
Mitigation: call tailscale serve reset before starting the portal after any unclean shutdown. Or add a startup cleanup step to internal/tailscale:
// Optional: clear all serve routes on portal startup before registering new ones.func (s *Serve) Reset() error { cmd := exec.Command(s.Binary, "serve", "reset") out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("tailscale serve reset: %w\n%s", err, out) } return nil}Call Reset() in Start() before the session manager is initialised, only when tailscale.enabled: true.
Lesson 10 — Checklist
Section titled “Lesson 10 — Checklist”Before relying on Tailscale for daily remote access:
Tailscale setup
Section titled “Tailscale setup”-
tailscale statusshows the machine connected with a.ts.netFQDN - Machine name is short and stable (not
your-macbook-pro-m2-2023) - Key expiry is disabled on the portal machine (admin console → Machines → machine → Disable key expiry)
- MagicDNS is enabled (admin console → DNS page)
- HTTPS certificates are enabled (same DNS page)
Portal exposure
Section titled “Portal exposure”-
tailscale serve --bg --https=443 http://localhost:4000has been run -
tailscale serve statusshows the portal route -
https://<machine>.ts.netopens the portal from another device on the tailnet - The browser shows a valid certificate (no warning)
Session exposure
Section titled “Session exposure”-
tailscale.enabled: trueis set inconfig.yaml - Starting an OC or script session from the portal produces an HTTPS URL
-
tailscale serve statusshows the session port after starting - Stopping the session removes its port from
tailscale serve status
Resilience
Section titled “Resilience”- Restarted the portal (
launchctl stop / start) and confirmed serve routes persisted (they are--bgregistered, not managed by the portal process itself) - Rebooted the machine and confirmed the portal is accessible and
tailscale serve statusshows the portal route
Summary
Section titled “Summary”The portal is now fully integrated with Tailscale:
- Installed — standalone
.pkg(recommended) or Homebrew CLI - Connected — machine on tailnet, named and key expiry disabled
- MagicDNS enabled —
<machine>.ts.netresolves across all tailnet devices - HTTPS enabled — valid Let’s Encrypt certs provisioned via Tailscale
- Portal exposed —
https://<machine>.ts.netaccessible from phone, tablet, and any tailnet device - Sessions exposed — each OpenCode/VS Code/script session gets its own HTTPS port, registered on start and deregistered on stop
- Go module implemented —
internal/tailscale.Serveshells out to the CLI;NoopRegistrarhandles the disabled path transparently
The complete round-trip from “tap Open OpenCode in mobile browser” to “OpenCode running in a browser tab, accessible via HTTPS” is now in place.