The *arr Stack: The full build guide (Linode + Terraform + cloud-init + VPN-only HTTPS)
The Plan
Terraform with Linode VMs to set up an *arr stack
This is for my own sanity. If it reads like I’m talking to myself, that’s because I am.
The goal is: write Terraform, hit apply, and stand up a Linode VM that boots into a known-good state and runs an *arr stack. Then be able to tear it down and rebuild without crying, bargaining, or manually SSHing in to “just do one quick thing”.
Also: I’m aiming for private-by-default. Internal HTTPS, reachable over my VPN, not exposed to the internet.
And yes, *arr apps are general automation tools. I’m focusing on infra, security, and deployment hygiene, not on anything dodgy.
The Build
By the end of this post you’ll have:
- A Linode VM provisioned with Terraform
- cloud-init bootstrapping:
- a non-root admin user
- Docker + Compose plugin
- UFW baseline
- WireGuard server (VPN)
- rclone + a daily backup job to Object Storage
- Linode Cloud Firewall at the edge (default deny inbound)
- A VPN-only reverse proxy (Caddy) with internal HTTPS
- A starter Compose deploy (Prowlarr/Sonarr/Radarr/Bazarr behind Caddy)
This is the “enterprise cosplay” version: sane defaults, repeatable, and documented.
Architecture
Internet
|
| (only UDP 51820 open)
v
[WireGuard VPN on VM] 10.44.0.1
|
| 443 (VPN interface only)
v
[Caddy reverse proxy] ---> [*arr containers on docker network]
|
+---> /srv/appdata (configs)
+---> /srv/downloads
+---> /srv/media
+---> backups -> Object Storage (S3)
Important storage reality check: object storage (S3) is not a filesystem. I’m using it for backups (and optionally bulk media), not for sqlite/database/config files.
Repo layout
This is the shape I’m using so Terraform doesn’t become a junk drawer:
repo/
bootstrap/
cloud-init/
cloud-config.yaml.tftpl
infra/
envs/
dev/
versions.tf
variables.tf
main.tf
firewall.tf
storage.tf
outputs.tf
terraform.tfvars.example
apps/
compose/
arr/
compose.yaml
Caddyfile
.env.example
.gitignore
Prereqs
- Terraform installed (1.x)
- Linode account + API token
- SSH keypair locally
- A device you can run WireGuard on (laptop/desktop/phone)
Step 1: Linode token (don’t commit it)
Export token in your shell:
export LINODE_TOKEN="paste-it-here"
Step 2: Terraform scaffolding
.gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
crash.log
crash.*.log
infra/envs/dev/versions.tf
terraform {
required_version = ">= 1.0.0"
required_providers {
linode = {
source = "linode/linode"
version = "~> 3.0"
}
}
}
infra/envs/dev/variables.tf
variable "region" { type = string }
variable "type" { type = string }
variable "image" { type = string }
variable "label" { type = string }
variable "authorized_key" {
type = string
description = "Your SSH public key line"
}
variable "admin_user" {
type = string
default = "arr"
description = "Non-root admin user created by cloud-init"
}
variable "timezone" {
type = string
default = "Europe/London"
description = "VM timezone"
}
# Cloud Firewall controls
variable "admin_cidr" {
type = string
description = "Your public IP in CIDR form, e.g. 203.0.113.10/32"
}
variable "enable_public_ssh" {
type = bool
default = true
description = "Allow SSH from admin_cidr at the edge"
}
variable "vpn_udp_port" {
type = number
default = 51820
description = "WireGuard UDP port"
}
# Object Storage (S3)
variable "obj_cluster_id" {
type = string
description = "Object Storage cluster id, e.g. eu-central-1"
}
variable "obj_bucket_label" {
type = string
description = "Bucket name (must be unique)"
}
# NOTE: safer than creating keys in Terraform (state)
variable "obj_access_key" {
type = string
sensitive = true
description = "Object Storage access key"
}
variable "obj_secret_key" {
type = string
sensitive = true
description = "Object Storage secret key"
}
infra/envs/dev/terraform.tfvars.example
region = "eu-west"
type = "g6-standard-1"
image = "linode/ubuntu24.04"
label = "arr-dev-1"
authorized_key = "ssh-ed25519 AAAA... you@machine"
admin_cidr = "YOUR.PUBLIC.IP/32"
enable_public_ssh = true
vpn_udp_port = 51820
obj_cluster_id = "eu-central-1"
obj_bucket_label = "arr-bucket-yourname-01"
# Put real values in terraform.tfvars (ignored by git)
obj_access_key = "..."
obj_secret_key = "..."
Copy it to terraform.tfvars locally.
Step 3: cloud-init (bootstrap the VM)
Create: bootstrap/cloud-init/cloud-config.yaml.tftpl
This template does:
- create
${admin_user} - install Docker + Compose plugin
- create
/srv/*directories - baseline UFW (deny inbound, allow outbound, allow SSH + WireGuard)
- configure WireGuard server on
10.44.0.1/24 - configure rclone to your bucket + daily backup of
/srv/appdata
#cloud-config
package_update: true
package_upgrade: true
timezone: "${timezone}"
users:
- default
- name: "${admin_user}"
shell: /bin/bash
groups: [sudo, docker]
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys:
- "${ssh_public_key}"
lock_passwd: true
ssh_pwauth: false
packages:
- ca-certificates
- curl
- ufw
- docker.io
- docker-compose-plugin
- wireguard
- rclone
runcmd:
# Folders for the stack
- [ sh, -lc, "mkdir -p /srv/{appdata,downloads,media}" ]
- [ sh, -lc, "chown -R ${admin_user}:${admin_user} /srv" ]
# Docker
- [ systemctl, enable, --now, docker ]
- [ sh, -lc, "usermod -aG docker ${admin_user}" ]
# UFW baseline (do NOT enable before allowing SSH)
- [ sh, -lc, "ufw default deny incoming" ]
- [ sh, -lc, "ufw default allow outgoing" ]
- [ sh, -lc, "ufw allow OpenSSH" ]
- [ sh, -lc, "ufw allow ${wg_port}/udp" ]
- [ sh, -lc, "ufw --force enable" ]
- [ sh, -lc, "ufw status verbose > /etc/ufw-status" ]
# WireGuard server keys + base config
- [ sh, -lc, "mkdir -p /etc/wireguard && chmod 700 /etc/wireguard" ]
- [ sh, -lc, "umask 077; test -f /etc/wireguard/server.key || (wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub)" ]
- [ sh, -lc, "cat > /etc/wireguard/wg0.conf <<'EOF'\n[Interface]\nAddress = 10.44.0.1/24\nListenPort = ${wg_port}\nPrivateKey = $(cat /etc/wireguard/server.key)\nSaveConfig = true\n\n# Peers added later\nEOF" ]
- [ sh, -lc, "chmod 600 /etc/wireguard/wg0.conf" ]
- [ sh, -lc, "systemctl enable --now wg-quick@wg0" ]
- [ sh, -lc, "cat /etc/wireguard/server.pub > /etc/wireguard/server_public_key.txt" ]
# Allow HTTPS only on wg0 later (Caddy binds to wg0 IP)
- [ sh, -lc, "ufw allow in on wg0 to any port 443 proto tcp" ]
# rclone config
- [ sh, -lc, "mkdir -p /etc/rclone && chmod 700 /etc/rclone" ]
- [ sh, -lc, "cat > /etc/rclone/rclone.conf <<'EOF'\n[linode]\ntype = s3\nprovider = Linode\nenv_auth = false\naccess_key_id = ${obj_access_key}\nsecret_access_key = ${obj_secret_key}\nendpoint = ${obj_endpoint}\nEOF" ]
- [ sh, -lc, "chmod 600 /etc/rclone/rclone.conf" ]
# Backup script: tar /srv/appdata to Object Storage
- [ sh, -lc, "cat > /usr/local/bin/backup-arr.sh <<'EOF'\n#!/usr/bin/env bash\nset -euo pipefail\nTS=$(date -u +%Y%m%dT%H%M%SZ)\nARCHIVE=/tmp/appdata-${TS}.tar.gz\ntar -C /srv -czf \"$ARCHIVE\" appdata\nrclone --config /etc/rclone/rclone.conf copy \"$ARCHIVE\" \"linode:${obj_bucket}/backups/appdata/\" --progress\nrm -f \"$ARCHIVE\"\nEOF" ]
- [ sh, -lc, "chmod 700 /usr/local/bin/backup-arr.sh" ]
# systemd timer for daily backup
- [ sh, -lc, "cat > /etc/systemd/system/backup-arr.service <<'EOF'\n[Unit]\nDescription=Backup *arr appdata to Object Storage\n\n[Service]\nType=oneshot\nExecStart=/usr/local/bin/backup-arr.sh\nEOF" ]
- [ sh, -lc, "cat > /etc/systemd/system/backup-arr.timer <<'EOF'\n[Unit]\nDescription=Daily backup of *arr appdata to Object Storage\n\n[Timer]\nOnCalendar=*-*-* 03:17:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target\nEOF" ]
- [ sh, -lc, "systemctl daemon-reload" ]
- [ sh, -lc, "systemctl enable --now backup-arr.timer" ]
# Breadcrumbs
- [ sh, -lc, "echo 'cloud-init: ok' > /etc/cloud-init-status" ]
Step 4: Object Storage bucket (Terraform)
Create: infra/envs/dev/storage.tf
data "linode_object_storage_cluster" "primary" {
id = var.obj_cluster_id
}
resource "linode_object_storage_bucket" "arr" {
cluster = data.linode_object_storage_cluster.primary.id
label = var.obj_bucket_label
}
Create: infra/envs/dev/outputs.tf
output "ipv4" { value = linode_instance.arr.ip_address }
output "wg_server_public_key" {
value = trimspace(file("/etc/does-not-exist"))
description = "Not available via Terraform; read it from the VM at /etc/wireguard/server_public_key.txt"
}
output "obj_bucket" { value = linode_object_storage_bucket.arr.label }
output "obj_endpoint" { value = "https://${data.linode_object_storage_cluster.primary.domain}" }
(That wg_server_public_key output is intentionally a no-op reminder: Terraform can’t read files from your VM without extra machinery. We’ll grab it over SSH.)
Step 5: VM + cloud-init (Terraform)
Create: infra/envs/dev/main.tf
provider "linode" {
# token comes from LINODE_TOKEN env var
}
locals {
obj_endpoint = "https://${data.linode_object_storage_cluster.primary.domain}"
cloud_config = templatefile("${path.module}/../../../bootstrap/cloud-init/cloud-config.yaml.tftpl", {
admin_user = var.admin_user
ssh_public_key = var.authorized_key
timezone = var.timezone
wg_port = var.vpn_udp_port
obj_bucket = linode_object_storage_bucket.arr.label
obj_endpoint = local.obj_endpoint
obj_access_key = var.obj_access_key
obj_secret_key = var.obj_secret_key
})
}
resource "linode_instance" "arr" {
label = var.label
region = var.region
type = var.type
image = var.image
authorized_keys = [var.authorized_key]
metadata {
user_data = base64encode(local.cloud_config)
}
tags = ["arr", "terraform", "dev"]
}
Step 6: Edge firewall (Linode Cloud Firewall)
Create: infra/envs/dev/firewall.tf
resource "linode_firewall" "arr_edge" {
label = "${var.label}-edge"
inbound_policy = "DROP"
outbound_policy = "ACCEPT"
dynamic "inbound" {
for_each = var.enable_public_ssh ? [1] : []
content {
label = "ssh-from-admin"
action = "ACCEPT"
protocol = "TCP"
ports = "22"
ipv4 = [var.admin_cidr]
}
}
inbound {
label = "wireguard"
action = "ACCEPT"
protocol = "UDP"
ports = tostring(var.vpn_udp_port)
ipv4 = ["0.0.0.0/0"]
ipv6 = ["::/0"]
}
linodes = [linode_instance.arr.id]
}
At this point, the only public inbound should be:
- SSH (from your IP) while we’re setting up
- WireGuard UDP
No public 80/443.
Step 7: Apply
From infra/envs/dev:
terraform init
terraform fmt -recursive
terraform validate
terraform plan
terraform apply
Grab the IP:
terraform output -raw ipv4
SSH in (public, for now):
ssh arr@$(terraform output -raw ipv4)
cloud-init status --wait
sudo cat /etc/cloud-init-status
sudo cat /etc/ufw-status
Step 8: WireGuard client setup
On your client machine:
umask 077
wg genkey | tee client.key | wg pubkey > client.pub
On the VM, read the server public key:
ssh arr@$(terraform output -raw ipv4)
sudo cat /etc/wireguard/server_public_key.txt
Create a client config (arr-vpn.conf):
[Interface]
PrivateKey = <contents of client.key>
Address = 10.44.0.2/32
DNS = 1.1.1.1
[Peer]
PublicKey = <server public key>
Endpoint = <vm-public-ip>:51820
AllowedIPs = 10.44.0.0/24
PersistentKeepalive = 25
Bring it up using your OS WireGuard client.
Then test:
ping 10.44.0.1
ssh arr@10.44.0.1
If that works, you’ve escaped “public SSH as a lifestyle”.
Close public SSH (optional but recommended)
Edit terraform.tfvars:
enable_public_ssh = false
Apply:
terraform apply
From now on, SSH in over VPN:
ssh arr@10.44.0.1
Step 9: Internal HTTPS reverse proxy (Caddy) + first Compose deploy
Copy the Compose files to the VM
You can keep these in your repo and scp them over, or just create them directly.
On the VM:
mkdir -p /srv/appdata/compose/arr
cd /srv/appdata/compose/arr
Create .env:
PUID=1000
PGID=1000
TZ=Europe/London
WG_IP=10.44.0.1
(Adjust PUID/PGID to match id output for your admin user.)
Create compose.yaml:
services:
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "${WG_IP}:443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [proxy]
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- /srv/appdata/prowlarr:/config
networks: [proxy]
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- /srv/appdata/sonarr:/config
- /srv/downloads:/downloads
- /srv/media:/media
networks: [proxy]
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- /srv/appdata/radarr:/config
- /srv/downloads:/downloads
- /srv/media:/media
networks: [proxy]
bazarr:
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
restart: unless-stopped
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- /srv/appdata/bazarr:/config
- /srv/media:/media
networks: [proxy]
networks:
proxy:
volumes:
caddy_data:
caddy_config:
Create Caddyfile:
sonarr.home.arpa {
tls internal
reverse_proxy sonarr:8989
}
radarr.home.arpa {
tls internal
reverse_proxy radarr:7878
}
bazarr.home.arpa {
tls internal
reverse_proxy bazarr:6767
}
prowlarr.home.arpa {
tls internal
reverse_proxy prowlarr:9696
}
DNS (quick and dirty)
On your client device (while VPN is connected), add hosts entries:
10.44.0.1 sonarr.home.arpa
10.44.0.1 radarr.home.arpa
10.44.0.1 bazarr.home.arpa
10.44.0.1 prowlarr.home.arpa
Later you can do this properly with internal DNS (Pi-hole/AdGuard/UniFi, etc).
Start the stack
On the VM:
cd /srv/appdata/compose/arr
docker compose pull
docker compose up -d
docker compose ps
Now, while connected to the VPN, visit:
https://sonarr.home.arpahttps://radarr.home.arpahttps://bazarr.home.arpahttps://prowlarr.home.arpa
Trust the internal CA (so browsers stop yelling)
Caddy tls internal uses a private CA. You need to trust it on your client devices.
Export the root cert:
cd /srv/appdata/compose/arr
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
Then install caddy-root.crt as a trusted root CA on your device(s).
Step 10: Backups (sanity check)
On the VM:
systemctl status backup-arr.timer --no-pager
sudo /usr/local/bin/backup-arr.sh
List objects in the bucket:
sudo rclone --config /etc/rclone/rclone.conf lsf "linode:${obj_bucket}/backups/appdata/"
If you see tarballs landing in Object Storage: good. Your VM can die without taking your config with it.
Optional: Remote state (so this isn’t “local laptop magic”)
This is where “enterprise mode” starts to feel real.
Terraform’s built-in S3 backend can talk to S3-compatible storage. You can use Linode Object Storage as the backend.
Create a file infra/envs/dev/backend.hcl (do not commit if it contains secrets):
bucket = "arr-bucket-yourname-01"
key = "terraform/dev/terraform.tfstate"
region = "us-east-1"
endpoint = "https://<your-obj-cluster-domain>"
skip_credentials_validation = true
skip_region_validation = true
skip_metadata_api_check = true
force_path_style = true
Then initialize with:
terraform init -reconfigure -backend-config=backend.hcl
You’ll need to export S3 credentials for the backend (separately from Linode API token), for example:
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
This is why I prefer keeping Object Storage keys out of Terraform state.
Optional: CI/CD (tiny and boring, the best kind)
Bare minimum GitHub Actions:
terraform fmt/validate/planon PR- apply on main (only if you’re confident and enjoy living dangerously)
I’m not pasting a full workflow here because everyone’s repo layout differs slightly, but the rules are:
- never echo secrets
- store
LINODE_TOKENin GitHub Secrets - if using remote state, store S3 keys in GitHub Secrets too
Common mistakes (collected from my future)
- Cloud Firewall set to “drop inbound” and then wondering why SSH died (it’s always the CIDR).
- UFW enabled before allowing SSH (rope cut, regrets achieved).
- Expecting S3 to behave like ext4 (it will not).
- Forgetting cloud-init won’t re-run the same way on an existing instance.
- Binding Caddy to
0.0.0.0:443and accidentally inventing a public service (don’t).
Cleanup
If you’re done testing:
cd infra/envs/dev
terraform destroy
If destroy fails because the bucket isn’t empty:
- delete bucket contents first
- retry destroy
The finished product (actual checklist)
terraform applygives me a VM with Docker + security baseline- WireGuard gives me private access
- Caddy gives me internal HTTPS
- *arr services are reachable over VPN only
- backups land in Object Storage
If I can nuke the VM and bring it back and still have my config, that’s the whole point.