Overview

This guide walks through provisioning an Oracle Cloud Infrastructure (OCI) free tier ARM instance and configuring it to serve two complementary roles on your Tailscale tailnet:

  1. Private peer relay — a Tailscale node that acts as a UDP relay for other tailnet devices that can't reach each other directly
  2. Custom DERP server — a dedicated TCP relay server that acts as a controlled fallback when all UDP paths fail

Why Both?

Tailscale's connection fallback order when direct peer-to-peer connections fail:

Direct UDP → Peer Relay UDP → DERP UDP → DERP TCP

By running both on the same OCI instance, you own every fallback tier. Tailscale's shared DERP servers are notoriously slow under load — this eliminates your dependence on them entirely.


Important Caveats and Ongoing Maintenance

Read this before starting. These are operational realities that will affect you after initial setup.

tailscaled and derper Must Be on the Same Version

This is documented by Tailscale and is non-negotiable when using -verify-clients. The derper binary uses the same internal APIs as tailscaled to verify connecting clients against your tailnet. If the versions are mismatched, client verification may fail silently or derper may refuse connections.

In practice this means:

A note on the -ERR-BuildInfo version suffix: If Go's system version is older than the minimum required by the tailscale module, Go will automatically download a newer toolchain to complete the build. This causes build metadata embedding to fail, resulting in a version string like 1.94.2-ERR-BuildInfo instead of a clean 1.94.2. This is purely cosmetic — it has zero functional impact on DERP relay, client verification, TLS, or STUN. The ts-update script (Part 7) handles this correctly by stripping suffixes before comparing versions.

⚠ Disable auto-update to maintain control

With auto-update enabled, tailscaled can update itself without you knowing — leaving derper on a mismatched version. Disable it and use ts-update (Part 7) to update both in sync:

sudo tailscale set --auto-update=false

TLS Certificates Expire Every 90 Days

Let's Encrypt certificates are valid for 90 days. Certbot's systemd timer handles automatic renewal, but the deploy hook (Step 18) must be in place to copy the renewed certs to /var/lib/derper/ and restart derper. Without the hook, certbot renews the cert but derper keeps serving the old expired one until manually restarted.

Verify your renewal pipeline is working end to end (see Part 5 — Testing).

OCI Always Free Idle Reclamation

OCI can stop free tier instances where CPU usage stays below 20% at the 95th percentile over 7 days. A relay/DERP node that isn't actively being used will be idle. Options:

Tailscale Key Expiry

By default, Tailscale node keys expire after 90 days. When a key expires, the node leaves your tailnet and relay/DERP functionality stops. Disable key expiry on your OCI node in the admin console under Machines → your node → ... → Disable key expiry.

Why OCI Free Tier?

OCI's Always Free tier is among the most generous in the industry. The full compute allocation for ARM instances per Oracle's documentation:

ℹ OCI Ampere A1 Always Free allocation

All tenancies get the first 3,000 OCPU hours and 18,000 GB hours per month for free for VM instances using the VM.Standard.A1.Flex shape (Arm processor). For Always Free tenancies, this is equivalent to 4 OCPUs and 24 GB of memory running continuously for a full month.

These resources can be allocated as a single large instance or split across up to four smaller instances.

Additional Always Free networking limits:

For this guide's use case — a relay/DERP server — 1 OCPU and 6 GB RAM is more than sufficient and costs nothing indefinitely. The 10 TB monthly egress cap is effectively unlimited for personal tailnet usage; you would need to sustain ~450 Mbps continuously around the clock to approach it.


Part 1 — OCI Instance Provisioning

Prerequisites

OCI Free Tier Gotchas (Read Before Starting)

Before touching the console, understand these:

ARM capacity is frequently exhausted

The Ampere A1 shape is popular and OCI regularly returns "Out of Capacity" errors. Retry in a different availability domain, wait a few hours, or upgrade to PAYG — you won't be charged for Always Free resources but PAYG unlocks the capacity pool.

OCI has TWO firewall layers — both must be configured
  1. VCN Security List — network-level firewall managed in the OCI console
  2. OS iptables — host-level firewall inside the instance (OCI pre-populates this with a default-deny REJECT rule)

A rule that exists in the Security List but lands in the wrong iptables position is silently dropped. This is the most common failure mode.

The OCI default REJECT rule is a trap

Ubuntu images on OCI ship with a REJECT icmp-host-prohibited rule at the end of the iptables INPUT chain. Any rules you add with -A (append) land AFTER this REJECT and are silently bypassed. You MUST use -I INPUT <N> (insert at position) to place rules BEFORE the REJECT. Covered in detail in Part 3.

VCN CIDR must contain subnet CIDR

If OCI auto-creates a VCN with 10.0.0.0/16, your subnet must be within that range (e.g. 10.0.0.0/24). Any other subnet CIDR will fail with a validation error.

VCN count limits

Free tier accounts have a limit on VCNs. If you hit it, reuse an existing VCN rather than creating a new one.

Idle reclamation

OCI can reclaim free tier instances where CPU usage stays below 20% at the 95th percentile over 7 days. Upgrading to PAYG eliminates this risk — you still pay nothing for Always Free resources but your instance won't be stopped.


Step 1 — Create the Instance

  1. Log into the OCI Console → Compute → Instances → Create Instance
  2. Name: choose something meaningful (this becomes the hostname)
  3. Availability Domain: try AD-1 first; if capacity errors occur, try AD-2 or AD-3
  4. Image: Click "Change Image" → Ubuntu → Ubuntu 24.04 Minimal aarch64
  5. Use minimal — no GUI, smaller attack surface, less overhead for a relay node
  6. Use aarch64 — this is the ARM64 architecture required for the Ampere A1 shape
  7. Shape: Click "Change Shape" → Ampere → VM.Standard.A1.Flex
  8. OCPUs: 1
  9. Memory: 6 GB
  10. Networking:
  11. Create a new VCN or select an existing one
  12. VCN CIDR: 10.0.0.0/16
  13. Subnet CIDR: 10.0.0.0/24 (must be within the VCN CIDR)
  14. Assign a public IPv4 address: Yes
  15. SSH Keys: paste your public key or upload the .pub file
  16. Click Create

Provisioning takes 2–5 minutes. Once the instance state shows Running, note the public IP address — you'll need it throughout this guide.


Step 2 — Initial SSH Access

OCI Ubuntu images create a default user named ubuntu. SSH in:

ssh -i /path/to/your/private/key [email protected]

The default ubuntu user can be renamed to match your preferred username. You can't rename a user you're actively logged in as, so the cleanest method is a cron job that runs at the next boot before any user session starts.

As root:

# Install cron (the package is named 'cron', not 'crontab')
sudo apt install cron -y

# Create the rename script
sudo cat > /tmp/renameuser.sh << 'EOF'
#!/bin/bash
usermod -l YOURNEWUSERNAME ubuntu
usermod -m -d /home/YOURNEWUSERNAME YOURNEWUSERNAME
groupmod -n YOURNEWUSERNAME ubuntu
crontab -r
EOF

sudo chmod +x /tmp/renameuser.sh

# Schedule at next reboot as root
echo "@reboot /tmp/renameuser.sh" | sudo crontab -

# Reboot
sudo reboot

After reboot, SSH back in with the new username. Your existing SSH key works unchanged since the authorized_keys file moves with the home directory.


Step 4 — System Updates

sudo apt update && sudo apt upgrade -y

Part 2 — Tailscale Installation and Peer Relay Configuration

What is a Peer Relay?

A peer relay is a Tailscale node that acts as a UDP relay for other nodes in your tailnet. When two tailnet devices can't connect directly (due to NAT, firewall, or network restrictions), they can route their WireGuard traffic through the relay node. The relay operates at the Tailscale application layer — no kernel IP forwarding required (unlike exit nodes or subnet routers).

Key facts:

Step 5 — Install Tailscale

curl -fsSL https://tailscale.com/install.sh | sh

Step 6 — Join Your Tailnet with a Tag

Tags allow you to apply ACL policies to the relay node. Create a tag:relay tag (or whatever name suits you) in your Tailscale admin console first under Settings → Access Controls → tagOwners, then join:

sudo tailscale up --advertise-tags=tag:relay

Authenticate via the URL printed to the terminal.

Step 7 — Configure as Peer Relay

# Set the peer relay port (UDP 443 is recommended — passes most restrictive networks)
sudo tailscale set --relay-server-port=443

# Disable DNS from Tailscale (prevents timeout noise if running your own DNS stack)
sudo tailscale set --accept-dns=false

# Set yourself as operator so debug commands don't require sudo
sudo tailscale set --operator=$USER
⚠ Flag interaction gotcha — read before running tailscale up

Step 8 — Verify Peer Relay Configuration

Confirm both settings are present simultaneously:

sudo tailscale debug prefs | grep -E "AdvertiseTags|RelayServerPort"

Expected output:

"AdvertiseTags": ["tag:relay"],
"RelayServerPort": 443,

Confirm other tailnet nodes see this node as a relay:

sudo tailscale debug peer-relay-servers

Expected output:

["100.x.x.x"]

If this returns your node's Tailscale IP, the control plane is advertising it as a relay.

Step 9 — Tailscale ACL Grant for Peer Relay

In the Tailscale admin console ACL policy, add a grant that allows tailnet members to use the relay. The src field specifies which devices can be relayed through this node, and dst specifies the relay nodes themselves:

// allow tailnet members and tagged devices to use private peer relays
{
    "src": ["autogroup:member", "autogroup:tagged"],
    "dst": ["tag:relay"],
    "app": {
        "tailscale.com/cap/relay": [],
    },
},
ℹ Note — ACL preview UI

This capability grant does not appear in the Tailscale ACL preview UI — that UI only shows network access rules. The grant is working correctly if tailscale debug peer-relay-servers returns your node's IP on client devices.


Part 3 — OCI and OS Firewall Configuration

This section covers both the OCI VCN Security List and OS-level iptables. Both layers must be configured correctly. The iptables gotcha is one of the most common sources of silent failures on OCI.

Understanding the OCI Double Firewall

Traffic inbound to your OCI instance passes through:

  1. VCN Security List — Oracle's network-level firewall. Configured in the OCI Console.
  2. OS iptables — the instance's own firewall. OCI pre-populates Ubuntu images with rules including a default-deny REJECT at the end of the INPUT chain.

A rule that exists in the Security List but not in iptables (or is in the wrong position in iptables) will be silently blocked. Always configure both.

Step 10 — OCI Security List Rules

In the OCI Console → Networking → Virtual Cloud Networks → your VCN → Security Lists → your security list:

Add the following Ingress Rules (all stateful):

ProtocolSourcePortPurpose
TCP0.0.0.0/022SSH (likely already present)
UDP0.0.0.0/041641Tailscale WireGuard
UDP0.0.0.0/0443Peer relay
TCP0.0.0.0/0443Custom DERP (add when setting up DERP in Part 4)
UDP0.0.0.0/03478STUN (add when setting up DERP in Part 4)

Step 11 — iptables Rules

This is the critical step. The default OCI Ubuntu iptables chain looks like this:

Chain INPUT (policy ACCEPT)
1. ACCEPT  state RELATED,ESTABLISHED
2. ACCEPT  icmp
3. ACCEPT  lo
4. ACCEPT  tcp dpt:22  state NEW
5. REJECT  all  reject-with icmp-host-prohibited   ← DEFAULT OCI RULE

Any rule appended with -A INPUT lands after line 5 and is NEVER reached. You must INSERT rules at position 5 (before the REJECT).

First, check your current chain and identify the REJECT line number:

sudo iptables -L INPUT -n -v --line-numbers

Insert your rules above the REJECT line (replace <N> with the REJECT line number):

# Tailscale WireGuard
sudo iptables -I INPUT <N> -p udp -m state --state NEW --dport 41641 -j ACCEPT

# Peer relay
sudo iptables -I INPUT <N> -p udp -m state --state NEW --dport 443 -j ACCEPT

# Custom DERP (TCP 443) — add when setting up DERP in Part 4
sudo iptables -I INPUT <N> -p tcp -m state --state NEW --dport 443 -j ACCEPT

# STUN — add when setting up DERP in Part 4
sudo iptables -I INPUT <N> -p udp -m state --state NEW --dport 3478 -j ACCEPT

Also fix the FORWARD chain — required for exit node and subnet routing functionality. If FORWARD only has a REJECT rule:

sudo iptables -L FORWARD -n -v --line-numbers
sudo iptables -I FORWARD 1 -i tailscale0 -j ACCEPT
sudo iptables -I FORWARD 1 -o tailscale0 -j ACCEPT

Save all rules persistently (survives reboots):

sudo netfilter-persistent save

Verify the final state — in every chain, ACCEPT rules must appear BEFORE the REJECT rule:

sudo iptables -L INPUT -n -v
sudo iptables -L FORWARD -n -v

Step 12 — IP Forwarding (Required for Exit Node / Subnet Router)

IP forwarding is NOT required for peer relay (which operates at the userspace application layer). It IS required if you also want to use this instance as a Tailscale exit node or subnet router.

Check current state:

sysctl net.ipv4.ip_forward
sysctl net.ipv6.conf.all.forwarding

If either returns 0 and you need exit node/subnet routing:

sudo nano /etc/sysctl.d/99-tailscale.conf

Contents:

net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

For exit node advertisement:

sudo tailscale set --advertise-exit-node

Then approve the exit node routes in the Tailscale admin console under Machines → your node → Edit route settings.


Part 4 — Custom DERP Server

What is a Custom DERP Server?

DERP (Designated Encrypted Relay for Packets) is Tailscale's last-resort relay protocol. When all UDP paths fail (direct, peer relay), Tailscale falls back to DERP over TCP 443, which looks like normal HTTPS traffic to firewalls and is essentially impossible to block on any network that allows web browsing.

By default, Tailscale uses its own shared DERP servers. These are often slow under load. Running your own DERP gives you a fast, controlled fallback that you own entirely.

ℹ Two things to understand before starting

Port separation: peer relay uses UDP 443, DERP uses TCP 443. Both coexist on the same port without conflict — UDP and TCP are separate protocols.

verify-clients: The -verify-clients flag restricts your DERP server to tailnet members only. Without it, your server is open to the entire internet. This flag is non-negotiable.

Step 13 — DNS Record in Cloudflare

Create an A record pointing to your OCI public IP:

TypeNameContentProxy
AderpYOUR.OCI.PUBLIC.IPDNS only (grey cloud)
⛔ Critical — do not proxy this record

Set to DNS only (grey cloud), NOT proxied (orange cloud). Cloudflare's proxy does not support arbitrary TCP passthrough on port 443. DERP requires a direct TLS connection to your instance. Proxying will silently break it.

Use whatever hostname fits your domain — this guide uses derp.yourdomain.com as a placeholder.

Step 14 — Install Go

derper is compiled from source. Go 1.21 or later is required.

go version

If not installed or version is too old:

sudo apt update
sudo apt install -y golang-go
go version

If the apt version is below 1.21, install manually for ARM64:

curl -OL https://go.dev/dl/go1.22.3.linux-arm64.tar.gz
sudo tar -C /usr/local -xzf go1.22.3.linux-arm64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
go version

Step 15 — Build and Install derper

⚠ Do not use sudo for go install

Run go install as your regular user. If you use sudo, the binary lands in /root/go/bin/ instead of ~/go/bin/ and the install step will fail to find it.

go install tailscale.com/cmd/[email protected]  # pin to exact tailscaled version
sudo mv ~/go/bin/derper /usr/local/bin/derper
derper -version

Step 16 — TLS Certificate via Cloudflare DNS Challenge

derper requires a valid TLS certificate. Using Certbot with the Cloudflare DNS plugin means no web server is needed and port 80 never needs to be opened.

Install Certbot and Cloudflare plugin:

sudo apt update
sudo apt install -y certbot python3-certbot-dns-cloudflare

Create Cloudflare API Token:

In the Cloudflare dashboard:

  1. My Profile → API Tokens → Create Token
  2. Use Edit zone DNS template
  3. Permissions: Zone / DNS / Edit
  4. Zone Resources: Include / Specific zone / yourdomain.com
  5. Client IP Address Filtering: add your OCI instance's public IP — scopes the token so only your server can use it even if the token value is ever compromised
  6. Create Token — copy the value immediately (shown only once)

Create credentials file:

sudo nano /etc/cloudflare.ini

Contents:

dns_cloudflare_api_token = YOUR_TOKEN_HERE
sudo chmod 600 /etc/cloudflare.ini

Request certificate:

sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/cloudflare.ini \
  -d derp.yourdomain.com \
  --agree-tos \
  --email [email protected]

Certbot creates a _acme-challenge TXT record in Cloudflare, Let's Encrypt validates it, then the record is cleaned up automatically.

Certificates land at:

Verify:

sudo certbot certificates
sudo systemctl status certbot.timer

Step 17 — Create derper Service

About derper's two separate files:

derper uses two distinct files that are easy to confuse:

  1. -c (key file) — a small JSON file containing only derper's private node key. Completely separate from TLS. Auto-generated on first run if it doesn't exist.
  2. -certdir — directory containing TLS cert files. In manual mode, derper expects files named <hostname>.crt and <hostname>.key — NOT fullchain.pem/privkey.pem. You must copy and rename them.

Create the derper user and directory:

sudo useradd -r -s /bin/false derper
sudo mkdir -p /var/lib/derper
sudo chown derper:derper /var/lib/derper

Copy certs with the names derper expects:

sudo cp /etc/letsencrypt/live/derp.yourdomain.com/fullchain.pem /var/lib/derper/derp.yourdomain.com.crt
sudo cp /etc/letsencrypt/live/derp.yourdomain.com/privkey.pem /var/lib/derper/derp.yourdomain.com.key
sudo chown derper:derper /var/lib/derper/derp.yourdomain.com.crt
sudo chown derper:derper /var/lib/derper/derp.yourdomain.com.key

Create the systemd service:

sudo nano /etc/systemd/system/derper.service

Contents:

[Unit]
Description=Tailscale DERP Server
After=network.target
Wants=network.target

[Service]
User=derper
Group=derper
ExecStart=/usr/local/bin/derper \
  -c /var/lib/derper/derper.key \
  -hostname=derp.yourdomain.com \
  -certmode=manual \
  -certdir=/var/lib/derper \
  -http-port=-1 \
  -a=:443 \
  -verify-clients \
  -stun \
  -stun-port=3478
Restart=always
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target

Flag explanations:

FlagPurpose
-cPath to derper's node key file. Auto-generated on first run. NOT the TLS cert.
-hostnameMust match TLS certificate CN exactly
-certmode=manualUse cert files from -certdir rather than auto-provisioning
-certdirDirectory containing <hostname>.crt and <hostname>.key
-http-port=-1Disables HTTP listener on port 80. Tailscale clients never use port 80 for DERP.
-a=:443DERP listens on TCP 443
-verify-clientsOnly allows nodes on your tailnet. Critical — without this your server is open to the internet.
-stunEnables STUN for endpoint discovery
-stun-port=3478Standard STUN port (UDP only — STUN is never TCP)
⛔ Single-dash flags only

derper uses single-dash flags (-flag), not double-dash (--flag). Double-dash flags cause an immediate INVALIDARGUMENT error at startup. Every flag in the ExecStart must use a single dash.

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable derper
sudo systemctl start derper
sudo journalctl -u derper -f

A healthy startup looks like:

derper: STUN server listening on [::]:3478
derper: No mesh key configured
derper: serving on :443 with TLS

Step 18 — Certificate Auto-Renewal Hook

certbot auto-renews into /etc/letsencrypt/live/ but derper reads from /var/lib/derper/ with specific filenames. A deploy hook copies renewed certs into place and restarts derper automatically.

sudo nano /etc/letsencrypt/renewal-hooks/deploy/renew-derper-certs.sh

Contents:

#!/bin/bash
cp /etc/letsencrypt/live/derp.yourdomain.com/fullchain.pem /var/lib/derper/derp.yourdomain.com.crt
cp /etc/letsencrypt/live/derp.yourdomain.com/privkey.pem /var/lib/derper/derp.yourdomain.com.key
chown derper:derper /var/lib/derper/derp.yourdomain.com.crt
chown derper:derper /var/lib/derper/derp.yourdomain.com.key
systemctl restart derper
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/renew-derper-certs.sh

Test the renewal pipeline (dry run — does not actually renew):

sudo certbot renew --dry-run

Step 19 — Add Custom DERP Region to Tailscale ACL

In the Tailscale admin console ACL, add a derpMap section.

⛔ HomeParams/RegionScore does not work via the ACL policy file

HomeParams.RegionScore exists in Tailscale's codebase and is documented as a way to bias region selection, but it is not distributed to clients via the ACL policy file — it is a control-plane-internal mechanism only. Setting it in your ACL has no effect. Clients select their home DERP region based purely on measured latency regardless.

There is no single clean "prefer my DERP but fall back gracefully" knob available through the ACL. You have three practical options below.

Option A — Null out competing regions (recommended)

Remove the Tailscale-provided regions with lower latency than your server. Tailscale picks the lowest-latency available region, so removing faster competition forces your server to win. If your server goes down, clients fall back to the next nearest remaining region — you retain a fallback.

Run tailscale netcheck and look at the DERP latency list — null out every region that beats your server. For a server in OCI Ashburn, all US regions will typically beat it:

Region IDCodeLocation
1nycNew York City
2sfoSan Francisco
9dfwDallas
10seaSeattle
12ordChicago
13denDenver
16miaMiami
17laxLos Angeles
21torToronto
27iadAshburn

Adjust this list based on your geography and netcheck results. You can retrieve the full Tailscale region ID list at any time:

curl -s https://controlplane.tailscale.com/derpmap/default | python3 -c 'import sys,json; [print(str(v["RegionID"]).rjust(4), v["RegionCode"].ljust(6), v["RegionName"]) for v in json.load(sys.stdin)["Regions"].values()]'
	// custom DERP — null out US regions so your server wins by latency
	// clients fall back to London, Frankfurt, etc. if your server is unreachable
	"derpMap": {
		"OmitDefaultRegions": false,
		"Regions": {
			"1":  null,
			"2":  null,
			"9":  null,
			"10": null,
			"12": null,
			"13": null,
			"16": null,
			"17": null,
			"21": null,
			"27": null,
			"900": {
				"RegionID":   900,
				"RegionCode": "custom",
				"RegionName": "Custom DERP",
				"Nodes": [
					{
						"Name":     "custom-1",
						"RegionID": 900,
						"HostName": "derp.yourdomain.com",
						"DERPPort": 443,
						"STUNPort": 3478,
						"STUNOnly": false,
					},
				],
			},
		},
	},

Option B — OmitDefaultRegions: true (no fallback)

⛔ This option can break all tailnet connectivity

DERP is not only used as a relay for established connections — it is also used during the initial connection bootstrap when a node joins the tailnet. If your custom DERP server is down and no other DERP regions are available, nodes cannot complete the coordination handshake at all. This means total connectivity loss, not just degraded performance. Nodes may be unable to reach each other or even authenticate to the tailnet until your server comes back online.

Only use this option if you have very high confidence in your server's reliability and uptime, and you fully accept the risk of complete tailnet outage if it goes down.

Remove all Tailscale-provided regions entirely. Your server is the only DERP option.

	// custom DERP only — no Tailscale regions, no fallback
	"derpMap": {
		"OmitDefaultRegions": true,
		"Regions": {
			"900": {
				"RegionID":   900,
				"RegionCode": "custom",
				"RegionName": "Custom DERP",
				"Nodes": [
					{
						"Name":     "custom-1",
						"RegionID": 900,
						"HostName": "derp.yourdomain.com",
						"DERPPort": 443,
						"STUNPort": 3478,
						"STUNOnly": false,
					},
				],
			},
		},
	},

Option C — Pure latency selection (simplest)

Add your custom region alongside Tailscale's regions and let latency decide. Your server will be used when it genuinely wins — which happens for nodes in geographies where Tailscale's servers are far away (e.g. traveling internationally). For nodes close to Tailscale's own servers, those will be selected instead.

	// custom DERP added alongside Tailscale's regions — pure latency selection
	"derpMap": {
		"OmitDefaultRegions": false,
		"Regions": {
			"900": {
				"RegionID":   900,
				"RegionCode": "custom",
				"RegionName": "Custom DERP",
				"Nodes": [
					{
						"Name":     "custom-1",
						"RegionID": 900,
						"HostName": "derp.yourdomain.com",
						"DERPPort": 443,
						"STUNPort": 3478,
						"STUNOnly": false,
					},
				],
			},
		},
	},

Save the policy. Changes propagate to all tailnet nodes within a minute or two.

ℹ Verify which DERP region a node is actually using
tailscale status --json | python3 -c "import sys,json; d=json.load(sys.stdin); print('Relay:', d.get('Self',{}).get('Relay','not found'))"

Returns the actual home DERP region code (e.g. nex, mia, iad). Use this to confirm your node is homed to your custom region after making ACL changes.


Part 5 — Testing

This section covers how to verify that everything is actually working at the data plane level — not just that the control plane thinks it should work. This distinction matters enormously.

The lesson: control plane checks (prefs, peer-relay-servers, netcheck) only confirm that Tailscale's coordination layer is configured correctly. They do NOT confirm that packets are actually flowing. Always test the data plane.

Test 1 — Verify Peer Relay Configuration

On the OCI instance:

sudo tailscale debug prefs | grep -E "AdvertiseTags|RelayServerPort"

Expected:

"AdvertiseTags": ["tag:relay"],
"RelayServerPort": 443,

On a client node:

sudo tailscale debug peer-relay-servers

Expected: your OCI instance's Tailscale IP in the list.

Test 2 — Verify UDP 443 is Actually Open (Data Plane)

The correct way to verify UDP 443 is reachable is not nmap (which can't definitively distinguish open from filtered for UDP) but tcpdump on the server while sending from a client.

On the OCI instance (stop tailscaled first to free the port):

sudo systemctl stop tailscaled
sudo nc -lup 443

Verify it's listening:

sudo ss -ulnp | grep 443

From a client machine (use bash explicitly for /dev/udp support):

bash -c 'echo "testing" > /dev/udp/YOUR.OCI.PUBLIC.IP/443'

If "testing" appears in the nc session on the OCI instance, UDP 443 is genuinely open end to end. Restart tailscaled afterward and re-run the peer relay config commands.

Test 3 — Verify DERP is Reachable (TCP 443)

From any client node:

curl -v https://derp.yourdomain.com/derp/probe

Expected: HTTP 200 with a completed TLS handshake. The TLS handshake completing is itself proof that TCP 443 is open through both the OCI Security List AND iptables. This is the most important DERP test.

Test 4 — Verify Tailscale Nodes See Your Custom DERP

tailscale netcheck

Look for your custom region (RegionName) in the DERP latency list with a measured latency value. A -- instead of a latency means derper is unreachable from that node.

Your custom region should appear in the DERP latency list with a measured latency value. If you used Option A (nulled competing regions), it should show as Nearest DERP. The fact that netcheck measures latency to your region also proves STUN is working — netcheck uses STUN for its measurements.

The fact that netcheck measures a latency to your region also proves STUN is working — netcheck uses STUN for latency measurements.

Test 5 — Verify Peer Relay Sessions

On the OCI instance:

tailscale debug peer-relay-sessions

This shows active relay sessions — pairs of disco peers being relayed through your node. Sessions with non-zero Packets/Bytes confirm live relay traffic is flowing.

Test 6 — Watch Metrics in Real Time

watch -n 2 'tailscale metrics print | grep peer_relay_forwarded'

Non-zero values here are definitive proof of relay packet forwarding. Unlike all the control plane checks, this measures actual data plane activity.

Test 7 — The Definitive Test: tcpdump

This is the only test that confirms packets are flowing through your server with absolute certainty.

# Watch for peer relay and DERP traffic on TCP 443
sudo tcpdump -i any tcp port 443 -n

# Watch for STUN and peer relay traffic on UDP 443 and 3478
sudo tcpdump -i any udp port 443 or udp port 3478 -n

With tailscaled running, initiate traffic between tailnet nodes from a separate terminal. You should see UDP packets flowing through port 443 for peer relay, and TCP connections on port 443 for DERP when UDP is blocked.

Test 8 — Simulate a Restricted Network (Force DERP Usage)

The best real-world test of your custom DERP is to simulate the conditions that would trigger it — a network with all UDP blocked.

On your home router/firewall, create a test VLAN with a rule that blocks all outbound UDP. When a device is on this VLAN:

If you used Option A or B from Step 19, it should use your custom DERP instead of Tailscale's servers.

Verify on the test device:

tailscale status
# Should show "relay custom" (your RegionCode) instead of "relay mia" etc.

And on your OCI instance, tcpdump should show TCP 443 connections from the test device's public IP.


Part 6 — Hardening (Post-Testing)

Step 20 — Disable the derper Status Page

By default, derper serves a status page at https://derp.yourdomain.com/ showing uptime, connected clients, and bytes forwarded. Useful during setup, unnecessary afterward — and it lets internet scanners fingerprint your server.

Edit the service:

sudo nano /etc/systemd/system/derper.service

Add -home=blank to the ExecStart flags:

ExecStart=/usr/local/bin/derper \
  -c /var/lib/derper/derper.key \
  -hostname=derp.yourdomain.com \
  -certmode=manual \
  -certdir=/var/lib/derper \
  -http-port=-1 \
  -a=:443 \
  -verify-clients \
  -stun \
  -stun-port=3478 \
  -home=blank
sudo systemctl daemon-reload
sudo systemctl restart derper

Step 21 — Disable Key Expiry on the OCI Node

In the Tailscale admin console → Machines → your OCI node → ... → Disable key expiry. This prevents the node from becoming unauthenticated after 90 days and dropping off your tailnet silently.

Step 22 — Verify Auto-Renewal Works

sudo certbot renew --force-renewal
sudo systemctl status derper

# Verify the cert date updated
openssl x509 -in /var/lib/derper/derp.yourdomain.com.crt -noout -dates

Part 7 — Ongoing Maintenance

ts-update — Unified Tailscale + derper Update Script

Because tailscaled and derper must always be on the same version, updating them separately is error-prone. The ts-update script handles both in a single operation: updates tailscaled, detects the new version, rebuilds derper pinned to that exact version, and verifies they match before finishing.

Key behaviors:

Install:

sudo nano /usr/local/bin/ts-update
# paste script contents below
sudo chmod +x /usr/local/bin/ts-update

Script contents:

#!/bin/bash
# /usr/local/bin/ts-update
# Updates tailscaled and rebuilds derper to match in a single operation.
# Run with sudo. Detects the invoking user automatically so the Go binary
# path is always correct regardless of who runs it.
#
# Usage: sudo ts-update

set -e

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

# Ensure script is running as root
if [ "$EUID" -ne 0 ]; then
    echo -e "${RED}✗ This script must be run with sudo: sudo ts-update${NC}"
    exit 1
fi

# Capture the real user who invoked sudo (not root)
REAL_USER=${SUDO_USER:-$USER}
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
GO_BIN="$REAL_HOME/go/bin/derper"

# Strip any suffix after the base version (e.g. -ERR-BuildInfo, -t..., etc.)
clean_version() {
    echo "$1" | grep -oP '^\d+\.\d+\.\d+'
}

echo -e "${CYAN}=== Tailscale + derper unified update ===${NC}"
echo -e "Running as: ${YELLOW}${REAL_USER}${NC} (Go bin: ${REAL_HOME}/go/bin)"
echo ""

# --- Pre-update state ---
BEFORE_TS_RAW=$(tailscaled --version | head -1 | awk '{print $1}')
BEFORE_DERPER_RAW=$(derper -version 2>&1 | head -1 | awk '{print $1}')
BEFORE_TS=$(clean_version "$BEFORE_TS_RAW")
BEFORE_DERPER=$(clean_version "$BEFORE_DERPER_RAW")

echo -e "Before:  tailscaled ${YELLOW}${BEFORE_TS_RAW}${NC}  |  derper ${YELLOW}${BEFORE_DERPER_RAW}${NC}"
echo ""

# --- Update tailscaled ---
echo -e "${CYAN}[1/4] Checking tailscaled for updates...${NC}"
tailscale update

NEW_TS_RAW=$(tailscaled --version | head -1 | awk '{print $1}')
NEW_TS=$(clean_version "$NEW_TS_RAW")

if [ "$NEW_TS" = "$BEFORE_TS" ]; then
    echo -e "${GREEN}tailscaled is already up to date (${NEW_TS})${NC}"
else
    echo -e "${GREEN}tailscaled updated: ${BEFORE_TS} → ${NEW_TS}${NC}"
fi
echo ""

# --- Check if derper rebuild is needed ---
if [ "$NEW_TS" = "$BEFORE_TS" ] && [ "$BEFORE_TS" = "$BEFORE_DERPER" ]; then
    echo -e "${GREEN}✓ tailscaled and derper are already in sync at ${NEW_TS} — nothing to do${NC}"
    exit 0
fi

# --- Rebuild derper pinned to exact tailscaled version as the real user ---
echo -e "${CYAN}[2/4] Rebuilding derper @ v${NEW_TS} as ${REAL_USER}...${NC}"
sudo -u "$REAL_USER" go install tailscale.com/cmd/derper@v${NEW_TS}

if [ ! -f "$GO_BIN" ]; then
    echo -e "${RED}✗ derper binary not found at ${GO_BIN}${NC}"
    echo -e "${RED}  Ensure Go is installed for ${REAL_USER} and ~/go/bin is in PATH${NC}"
    exit 1
fi

echo -e "${CYAN}[3/4] Installing derper binary to /usr/local/bin...${NC}"
mv "$GO_BIN" /usr/local/bin/derper

# --- Restart derper ---
echo -e "${CYAN}[4/4] Restarting derper...${NC}"
systemctl restart derper
sleep 2

# --- Post-update verification ---
AFTER_TS_RAW=$(tailscaled --version | head -1 | awk '{print $1}')
AFTER_DERPER_RAW=$(derper -version 2>&1 | head -1 | awk '{print $1}')
AFTER_TS=$(clean_version "$AFTER_TS_RAW")
AFTER_DERPER=$(clean_version "$AFTER_DERPER_RAW")
DERPER_STATUS=$(systemctl is-active derper)

echo ""
echo -e "${CYAN}=== Result ===${NC}"
echo -e "tailscaled: ${GREEN}${AFTER_TS_RAW}${NC}"
echo -e "derper:     ${GREEN}${AFTER_DERPER_RAW}${NC}"
echo -e "derper svc: ${GREEN}${DERPER_STATUS}${NC}"
echo ""

if [ "$AFTER_TS" = "$AFTER_DERPER" ]; then
    echo -e "${GREEN}✓ Versions in sync${NC}"
else
    echo -e "${RED}✗ Version mismatch — derper ${AFTER_DERPER} != tailscaled ${AFTER_TS}${NC}"
    echo -e "${RED}  Check: sudo journalctl -u derper -n 20${NC}"
    exit 1
fi

Usage: sudo ts-update


Custom MOTD with Version Monitoring

A custom MOTD (Message of the Day) that displays on every SSH login is the most practical way to stay aware of when tailscaled and derper need updating. Rather than remembering to check manually, the version state is surfaced automatically every time you log in.

The MOTD checks:

Install location: /etc/profile.d/z-custom-motd.sh

The z- prefix ensures it runs last among profile.d scripts. The .sh extension is required — only files ending in .sh are sourced by /etc/profile.d/.

The file must be executable:

sudo chmod +x /etc/profile.d/z-custom-motd.sh

Disable the default Ubuntu MOTD (optional but recommended — avoids duplicate output):

sudo chmod -x /etc/update-motd.d/*

Example MOTD script — customize the ASCII art and any instance-specific labels to suit your setup:

#!/bin/bash
# /etc/profile.d/z-custom-motd.sh
# Custom MOTD with tailscale/derper version monitoring.
# Must be chmod +x to be sourced by /etc/profile.d/

# --- Configuration & Colors ---
C_BOLD='\e[1m'
C_WHITE='\e[38;5;250m'
C_CYAN='\e[36m'
C_RED='\e[31m'
C_BLINK='\e[5m'
C_DIM='\e[2m'
NC='\e[0m'

# --- Logic: Stats ---
LOAD=$(awk '{print $1}' /proc/loadavg)
MEM_USED=$(free -m | awk '/Mem:/ { print $3 }')
MEM_TOTAL=$(free -m | awk '/Mem:/ { print $2 }')
MEM_PCT=$(( 100 * $MEM_USED / $MEM_TOTAL ))
DISK_PCT=$(df -h / | awk 'NR==2 {print $5}' | sed 's/%//')
DISK_INFO=$(df -h / | awk 'NR==2 {print $3 "/" $2 " (" $5 ")"}')

# --- Logic: Tailscale & Derper Update Check ---
TS_INSTALLED=$(tailscaled --version | head -n1 | awk '{print $1}')
TS_CANDIDATE=$(apt-cache policy tailscale | grep Candidate | awk '{print $2}' | cut -d- -f1)

# Sanitize Derper version — removes -ERR-BuildInfo or any suffix
DERP_RAW=$(derper -version 2>&1 | head -n1)
DERP_INSTALLED=$(echo "$DERP_RAW" | sed 's/[- ].*//')

# Logic Flags
VERSION_MISMATCH=""
UPDATE_AVAILABLE=""

[ "$TS_INSTALLED" != "$DERP_INSTALLED" ] && VERSION_MISMATCH="TRUE"
[ "$TS_INSTALLED" != "$TS_CANDIDATE" ] && UPDATE_AVAILABLE="TRUE"

# Color for Memory
[ $MEM_PCT -gt 85 ] && MEM_CLR=$C_RED || MEM_CLR=$C_WHITE

# --- The Banner (customize ASCII art to taste) ---
echo -e "${C_WHITE}"
echo '  ██████╗ ███████╗██████╗ ██████╗ '
echo '  ██╔══██╗██╔════╝██╔══██╗██╔══██╗'
echo '  ██║  ██║█████╗  ██████╔╝██████╔╝'
echo '  ██║  ██║██╔══╝  ██╔══██╗██╔═══╝ '
echo '  ██████╔╝███████╗██║  ██║██║     '
echo '  ╚═════╝ ╚══════╝╚═╝  ╚═╝╚═╝     '
echo -e "${NC}"

echo -e "${C_WHITE}┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓${NC}"
echo -e "${C_WHITE}┃${NC}  ${C_BOLD}INSTANCE:${NC}  $(hostname | tr '[:lower:]' '[:upper:]') (Oracle ARM)"
echo -e "${C_WHITE}┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫${NC}"
echo -e "${C_WHITE}┃${NC}  ${C_DIM}Address:${NC}   $(hostname -I | cut -d' ' -f1)"
echo -e "${C_WHITE}┃${NC}  ${C_DIM}Kernel:${NC}    $(uname -r)"
echo -e "${C_WHITE}┃${NC}  ${C_DIM}Uptime:${NC}    $(uptime -p | sed 's/up //')"
echo -e "${C_WHITE}┃${NC}"
echo -e "${C_WHITE}┃${NC}  ${C_BOLD}CPU Load:${NC}  ${C_CYAN}${LOAD}${NC} (1m avg)"
echo -e "${C_WHITE}┃${NC}  ${C_BOLD}Memory:${NC}    ${MEM_CLR}${MEM_USED}MB${NC} / ${MEM_TOTAL}MB (${MEM_PCT}%)"
echo -e "${C_WHITE}┃${NC}  ${C_BOLD}Storage:${NC}   ${DISK_INFO}"
echo -e "${C_WHITE}┃${NC}"

# --- Status / Alert Section ---
if [ "$UPDATE_AVAILABLE" == "TRUE" ]; then
    echo -e "${C_WHITE}┃${NC}  ${C_RED}${C_BOLD}${C_BLINK}UPDATE AVAILABLE${NC} — ${C_RED}${TS_INSTALLED}${NC} → ${C_CYAN}${TS_CANDIDATE}${NC}"
    echo -e "${C_WHITE}┃${NC}  ${C_DIM}HINT:${NC} Run ${C_BOLD}'sudo ts-update'${NC} to sync tailscaled & derper"
    echo -e "${C_WHITE}┃${NC}"
elif [ "$VERSION_MISMATCH" == "TRUE" ]; then
    echo -e "${C_WHITE}┃${NC}  ${C_RED}${C_BOLD}VERSION MISMATCH${NC}"
    echo -e "${C_WHITE}┃${NC}  ${C_DIM}Tailscaled:${NC} ${C_RED}${TS_INSTALLED}${NC}"
    echo -e "${C_WHITE}┃${NC}  ${C_DIM}Derper:${NC}     ${C_RED}${DERP_RAW}${NC}"
    echo -e "${C_WHITE}┃${NC}"
    echo -e "${C_WHITE}┃${NC}  ${C_DIM}HINT:${NC} Run ${C_BOLD}'sudo ts-update'${NC} to rebuild derper"
    echo -e "${C_WHITE}┃${NC}"
else
    echo -e "${C_WHITE}┃${NC}  ${C_DIM}TS/DERP Status:${NC} ${C_WHITE}Up to date (${TS_INSTALLED})${NC}"
fi

# --- Conditional Disk Alert ---
if [ "$DISK_PCT" -ge 90 ]; then
    echo -e "${C_WHITE}┃${NC}  ${C_RED}${C_BOLD}ALERT:${NC}     Disk space is critically low! (${DISK_PCT}%)${NC}"
fi

echo -e "${C_WHITE}┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛${NC}"
echo ""

What each section does:


Summary — Final Port Map

PortProtocolServiceNotes
22TCPSSHManagement
443TCPDERP TLSCustom DERP server
443UDPTailscale peer relayPeer relay
3478UDPSTUNEndpoint discovery (UDP only)
41641UDPTailscale WireGuardTailscale default WireGuard port

Port 80 is intentionally absent. DNS-01 certificate validation never requires port 80.


Troubleshooting

"Out of Capacity" when provisioning

OCI ARM capacity is shared and frequently exhausted on free tier. Try a different availability domain, wait and retry, or upgrade to PAYG (no charges for Always Free resources but unlocks capacity).

derper fails with INVALIDARGUMENT

derper uses single-dash flags only. Every --flag in your ExecStart must be changed to -flag. Check with sudo systemctl cat derper.

derper fails — cert not found

Verify derp.yourdomain.com.crt and derp.yourdomain.com.key exist in /var/lib/derper/ and are owned by derper. derper does NOT read fullchain.pem/privkey.pem directly.

sudo ls -la /var/lib/derper/
derper fails — key file permission denied

The /var/lib/derper/ directory must be owned by the derper user.

sudo chown -R derper:derper /var/lib/derper
UDP traffic silently blocked despite iptables rules

The most common OCI gotcha. Your ACCEPT rules are almost certainly landing AFTER the default OCI REJECT rule. Verify with line numbers — ACCEPT rules must appear before REJECT:

sudo iptables -L INPUT -n -v --line-numbers

If rules are in the wrong order, delete them and re-insert with -I INPUT <N> where <N> is the REJECT line number.

tailscale netcheck shows -- for custom DERP region
  1. Verify derper is running: sudo systemctl status derper
  2. Verify TCP 443 iptables rule is BEFORE the REJECT: sudo iptables -L INPUT -n -v --line-numbers
  3. Test TLS directly: curl -v https://derp.yourdomain.com/derp/probe
  4. Wait 2 minutes after saving ACL for propagation
peer-relay-servers returns empty

Both AdvertiseTags and RelayServerPort must be present simultaneously — verify both:

sudo tailscale debug prefs | grep -E "AdvertiseTags|RelayServerPort"
Custom DERP not being used as home region

HomeParams.RegionScore does not work via the ACL policy file — see Step 19. Use Option A (null competing regions) or Option B (OmitDefaultRegions) to force selection. With Option C, Tailscale picks purely by measured latency.

To check which region a node is actually homed to:

tailscale status --json | python3 -c "import sys,json; d=json.load(sys.stdin); print('Relay:', d.get('Self',{}).get('Relay','not found'))"

If the relay changed but the node still shows the old region, restart Tailscale on that node to force re-evaluation.

iptables rules not surviving reboot
sudo apt install iptables-persistent
sudo netfilter-persistent save