Posted on Jan 1, 1
---
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 ipv6 bits 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_cidr wrong 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”.