Posted on Jan 1, 1
---
title: "The *arr Stack, part 3: cloud-init bootstrap"
description: "Bootstrapping the VM without SSHing in like a caveman"
date: 2026-02-04T11:00:00
tldr: ""
draft: false
tags:
    - Article
---

## The Goal
In part 2 we created a VM that is basically an empty rental flat: it exists, it has a key, but there’s no furniture and the lights are flickering.

This part is about **cloud-init**. I want Terraform to:
- deploy the VM
- feed it a bootstrap config
- and then the VM comes up with:
  - a non-root user
  - Docker installed and started
  - basic folders created for the stack

All without me SSHing in and doing a little manual dance each time.

## What cloud-init is (and why I’m using it)
Cloud-init is a standard tool for initializing cloud instances. Linode (Akamai Connected Cloud) has a **Metadata service** that cloud-init reads on first boot, including any **user data** you attach at provisioning time. :contentReference[oaicite:0]{index=0}

So: we give the instance a small YAML file (cloud-config), the instance boots, cloud-init runs once, and we get a predictable baseline.

Also important: user data needs to be **base64 encoded** when it’s submitted. Terraform can do that for us. :contentReference[oaicite:1]{index=1}

## Repo changes
We’re going to add a cloud-init template so we can inject variables like username and SSH key.

```text
repo/
  bootstrap/
    cloud-init/
      cloud-config.yaml.tftpl
  infra/
    envs/
      dev/
        main.tf
        variables.tf
        outputs.tf
        versions.tf
        terraform.tfvars

Step 1: Create the cloud-init template

Create: bootstrap/cloud-init/cloud-config.yaml.tftpl

#cloud-config

package_update: true
package_upgrade: true

timezone: "${timezone}"

# Create a non-root user we can actually live in.
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

# No password SSH.
ssh_pwauth: false

packages:
  - ca-certificates
  - curl
  - docker.io
  - docker-compose-plugin

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 ]

  # Make sure the user can run docker without sudo (requires re-login to take effect).
  - [ sh, -lc, "usermod -aG docker ${admin_user}" ]

  # Tiny breadcrumb so I know cloud-init ran.
  - [ sh, -lc, "echo 'cloud-init: ok' > /etc/cloud-init-status" ]

Cloud-config files start with #cloud-config, and cloud-init will interpret the YAML directives. :contentReference[oaicite:2]{index=2}

A note on root

I’m not disabling root SSH in this step yet.

Reason: if you typo your YAML or your template variables, you can lock yourself out and spend quality time with the Linode console thinking about your life choices.

We’ll do the “fully locked down” version after we’ve got firewalls in place and we’ve proven the bootstrap is solid.

Step 2: Wire the template into Terraform

Edit: infra/envs/dev/variables.tf

Add:

variable "admin_user" {
  type        = string
  description = "Non-root user created by cloud-init"
  default     = "arr"
}

variable "timezone" {
  type        = string
  description = "Timezone for the VM"
  default     = "Europe/London"
}

Now edit: infra/envs/dev/main.tf

Add a locals block (near the top is fine):

locals {
  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
  })
}

Then update your linode_instance resource to include metadata user-data:

resource "linode_instance" "arr" {
  label  = var.label
  region = var.region
  type   = var.type
  image  = var.image

  authorized_keys = [var.authorized_key]

  metadata {
    # Linode expects this base64 encoded.
    user_data = base64encode(local.cloud_config)
  }

  tags = ["arr", "terraform", "dev"]
}

The Linode Metadata service accepts optional user data, and cloud-init consumes it on first boot. :contentReference[oaicite:3]{index=3}

Step 3: Apply

From infra/envs/dev:

terraform fmt -recursive
terraform validate
terraform plan
terraform apply

If this changes an existing instance, Terraform may choose to replace it depending on what changed. In this series, I’m fine with that because the whole point is: I can rebuild.

Step 4: Verify (did it actually do the thing?)

SSH in as your new user:

ssh arr@$(terraform output -raw ipv4)

Then check cloud-init:

cloud-init status --wait
sudo cat /etc/cloud-init-status

Check Docker:

docker version
docker compose version

Check folders:

ls -la /srv
ls -la /srv/appdata /srv/downloads /srv/media

If Docker permissions complain, log out and back in (group membership changes need a new session):

exit
ssh arr@<ip>
docker ps

Troubleshooting (the usual suspects)

cloud-init didn’t run

Check logs:

sudo journalctl -u cloud-init -b --no-pager
sudo tail -n 200 /var/log/cloud-init.log
sudo tail -n 200 /var/log/cloud-init-output.log

I changed the cloud-config and nothing happened

That’s cloud-init doing what it does: it runs on first boot, then it remembers it already initialized.

My rule: treat the VM as disposable and rebuild it with Terraform, at least while I’m iterating on bootstrap.

Cleanup

Same as before:

terraform destroy

Next part

Now the VM can run containers, which means it can also accidentally run containers while exposed to the whole internet if we’re not careful.

Next part is perimeter security:

  • Linode Cloud Firewall rules (tight inbound, sane outbound)
  • and an in-VM firewall baseline

So the only stuff that can talk to this box is the stuff I actually intend to talk to it.