---
title: "The *arr Stack, part 4: Perimeter security"
description: "Defence in depth, and how not to lock yourself out lel"
date: 2026-02-04T13:00:00
tldr: ""
draft: false
tags:
- Article
---
## The Goal
Right now we have:
- a VM
- a user
- Docker
- folders
Which is nice, but also means the VM is one typo away from being “publicly accessible surprise box”.
This part is about perimeter security using:
- **Linode Cloud Firewall** (network edge, outside the VM)
- **UFW** inside the VM (host firewall, inside the VM)
I’m doing both because I’m paranoid and I like sleeping.
## The shape of the rules
I’m keeping it simple:
Inbound:
- allow SSH **only from my IP**
- (optional) allow WireGuard UDP port for later VPN stuff
- everything else: drop
Outbound:
- allow (updates, pulling containers, DNS, etc)
If later I decide I actually want a public reverse proxy, I can add 80/443. But for now my stated goal is: **internal HTTPS and not exposed to the internet**.
## Step 0: Don’t brick your access
If you do this wrong you will lock yourself out.
Two escape hatches exist:
- Linode console access (Web console / Lish)
- Temporarily loosening the Cloud Firewall rule
Still: avoid the drama if possible.
## Terraform: Cloud Firewall
### Step 1: Add variables
Edit `infra/envs/dev/variables.tf` and add:
```hcl
variable "admin_cidr" {
type = string
description = "Your public IP in CIDR form, e.g. 203.0.113.10/32"
}
variable "vpn_udp_port" {
type = number
description = "UDP port to allow for VPN later (WireGuard default 51820)"
default = 51820
}
variable "enable_vpn_port" {
type = bool
description = "Whether to open the VPN UDP port in the Cloud Firewall"
default = true
}
Step 2: Set your IP in tfvars
Edit infra/envs/dev/terraform.tfvars:
admin_cidr = "YOUR.IP.ADDRESS/32"
enable_vpn_port = true
vpn_udp_port = 51820
Yes, your IP can change. Welcome to the joy of residential internet.
Step 3: Create the firewall resource
Edit infra/envs/dev/main.tf and add this after the instance:
resource "linode_firewall" "arr_edge" {
label = "${var.label}-edge"
inbound_policy = "DROP"
outbound_policy = "ACCEPT"
inbound {
label = "ssh-from-admin"
action = "ACCEPT"
protocol = "TCP"
ports = "22"
ipv4 = [var.admin_cidr]
}
dynamic "inbound" {
for_each = var.enable_vpn_port ? [1] : []
content {
label = "vpn-udp"
action = "ACCEPT"
protocol = "UDP"
ports = tostring(var.vpn_udp_port)
ipv4 = ["0.0.0.0/0"]
ipv6 = ["::/0"]
}
}
linodes = [linode_instance.arr.id]
}
This gives you “default deny inbound, default allow outbound”, plus the one thing you actually need.
Note:
- If you haven’t used IPv6 at all, the
ipv6bits don’t hurt. - If you don’t want the VPN port open yet, set
enable_vpn_port = false.
Step 4: Apply
From infra/envs/dev:
terraform fmt -recursive
terraform validate
terraform plan
terraform apply
In-VM firewall: UFW (defence in depth)
Cloud Firewall is great, but if someone ever attaches the wrong firewall, or if you later add a second interface, I still want the box to have opinions.
So: UFW.
Step 1: Add UFW to cloud-init
Edit bootstrap/cloud-init/cloud-config.yaml.tftpl.
Add ufw to packages:
packages:
- ca-certificates
- curl
- docker.io
- docker-compose-plugin
- ufw
Then add rules to runcmd (important: allow SSH before enabling):
runcmd:
# Folders for the stack.
- [ sh, -lc, "mkdir -p /srv/{appdata,downloads,media}" ]
- [ sh, -lc, "chown -R ${admin_user}:${admin_user} /srv" ]
# Docker, online.
- [ systemctl, enable, --now, docker ]
- [ sh, -lc, "usermod -aG docker ${admin_user}" ]
# UFW baseline: deny inbound, allow outbound.
- [ sh, -lc, "ufw default deny incoming" ]
- [ sh, -lc, "ufw default allow outgoing" ]
# If you do this after enabling UFW, you risk cutting your own rope.
- [ sh, -lc, "ufw allow OpenSSH" ]
# Optional: open WireGuard UDP for later VPN.
- [ sh, -lc, "ufw allow 51820/udp" ]
# Enable UFW non-interactively.
- [ sh, -lc, "ufw --force enable" ]
# Tiny breadcrumb so I know cloud-init ran.
- [ sh, -lc, "echo 'cloud-init: ok' > /etc/cloud-init-status" ]
- [ sh, -lc, "ufw status verbose > /etc/ufw-status" ]
Notes (because firewalls bite)
- UFW only runs on first boot via cloud-init.
- So if your VM already exists, either:
- rebuild it (my preference in this series), or
- SSH in and do the UFW steps manually (fine, but less “repeatable infra”)
Also: I hard-coded 51820/udp here to keep the template simple. If you want it variable-driven, you can template that too.
Step 2: Rebuild (my preferred method)
Because cloud-init:
terraform apply
If Terraform doesn’t replace the instance automatically, I’ll just do the blunt method:
terraform destroy
terraform apply
This is why we’re doing IaC.
Verify (trust, but verify)
SSH in:
ssh arr@$(terraform output -raw ipv4)
Check cloud-init:
cloud-init status --wait
sudo cat /etc/cloud-init-status
Check UFW:
sudo ufw status verbose
sudo cat /etc/ufw-status
Check what’s listening:
sudo ss -tulpn
From your own machine, you can also sanity check ports (replace IP):
nmap -Pn -p 1-1000 <server-ip>
You should basically only see 22 open (and maybe 51820/udp if you enabled it).
Common mistakes (I will make these again)
- Setting
admin_cidrwrong and wondering why SSH is dead. - Enabling UFW before allowing SSH.
- Forgetting your IP changed, because your ISP felt whimsical.
- Assuming “it’s just a hobby VM” means it can’t get pwned. It can.
Cleanup
Same deal:
terraform destroy
Next part
Now that the VM is not a public vending machine, we can do the next useful thing:
- start wiring in persistent storage properly
- and decide how I’m going to pretend an S3 bucket is a filesystem without crying
So: Part 5 is storage, mounts, and the “durability story”.