The *arr Stack: The full build guide (Linode + Terraform + cloud-init + VPN-only HTTPS)

Posted on 8 2026
tl;dr:

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”.

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.arpa
  • https://radarr.home.arpa
  • https://bazarr.home.arpa
  • https://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/plan on 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_TOKEN in 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:443 and 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 apply gives 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.