Host setup — first time¶
How to stand up and harden a new host, top to bottom on the day. Run this again unchanged for any additional host (a second shared box, or a dedicated one for a client) — nothing here is specific to the first.
Once it is running: host maintenance · adding a tenant
Read Known traps first — or at least know it exists
Ubuntu 24.04, Docker and Caddy each have a non-obvious behaviour that will cost you an evening if you meet it cold. All are handled in the roles; that page explains why the roles look the way they do, and turns a symptom into a diagnosis.
The hardening step is sequenced so you cannot lock yourself out — do not skip the two-phase SSH process.
Baseline decisions: ADR-0021 (host baseline), ADR-0022 (edge routing, TLS, hostnames), ADR-0023 (container isolation), ADR-0024 (Ansible layout), ADR-0025 (control-plane co-location), ADR-0026 (backups), ADR-0027 (secrets and credential custody). This runbook executes them; it doesn't re-decide. (These superseded ADR-0014, which bundled all of the above into one document.)
0. Before you start — have these ready¶
Do the Bitwarden step first. You are about to generate a Vault password and two SSH keypairs. If the shared store doesn't exist yet, they land in someone's personal manager "temporarily" and stay there — which is the exact bus-factor failure ADR-0027 exists to remove.
- Bitwarden Teams set up, shared collection created, both partners admins (ADR-0027 §6, OI-015). MFA on both accounts, and Bitwarden's own recovery codes stored outside Bitwarden.
- Vultr account with billing set up.
- Two SSH keypairs generated locally — one on El's machine, one on
Lachlan's. If not done:
ssh-keygen -t ed25519 -C "el@laptop"(repeat for Lachlan). You need the public keys (~/.ssh/id_ed25519.pub). Private keys are never shared (ADR-0021 §4) — a shared key can't be revoked for one person without locking out both, and destroys attribution. -
coreVault password created and put in Bitwarden immediately. Per-tenant vault passwords come later, at tenant provisioning (ADR-0027 §3). - Ansible installed on the machine you'll run from (via pipx, per the dev container setup).
- Scoped Cloudflare API token — DNS edit on
octopodia.com.auonly, never the global API key (ADR-0022 §9, OI-042). A global key in a playbook can delete records, which is the one thing we deliberately never automate. This token goes into Ansible Vault undercore, not Bitwarden — Ansible consumes it, so it is a machine credential (ADR-0027 §7). Not needed for hardening; needed for Caddy. - Provider root logins in Bitwarden: Vultr, Cloudflare, Micron21 (registrar), Microsoft 365, GitHub org owner, Railway — with their MFA recovery codes.
Not needed yet: Vultr object storage for backups (OI-036) — that arrives with the
backup role, several steps later.
Locked values (store the port in your password manager now):
| Thing | Value |
|---|---|
| Provider/region | Vultr — Melbourne |
| OS | Ubuntu 24.04 LTS |
| Host name | bos1 |
| SSH port | 52843 |
| Admin users | el, lachlan |
| Domain | octopodia.com.au (subdomains) |
0b. Naming — read this before touching DNS¶
The single idea: a host is not a service. The box gets its own name; the things running on it get theirs. That is what makes a client migration invisible later (OI-047).
Host names — the machine itself. Ops vocabulary only; a client never sees one.
| Situation | Name |
|---|---|
| Shared host | bos1, then bos2, … |
| Dedicated host for a client | {tenant}-bos1 — e.g. greenhills-bos1 |
Uses the tenant slug from ADR-0022, which is immutable — one vocabulary, not two.
No region label while everything is in one region (ADR-0008); add bos-syd1-style
names only if that ever changes.
The rule: one A record per host. Everything else is a CNAME to it.
Rebuild or resize the box and you edit exactly one record; inventory, SSH config
and every service name keep working.
| Name | Type | Points at | Who uses it |
|---|---|---|---|
bos1.octopodia.com.au |
A | the IP | Ops only — SSH, Ansible inventory. Not proxied (grey cloud). |
console.octopodia.com.au |
CNAME → bos1 |
the box | You. BOS Console, plus /health for monitoring. |
*.app.octopodia.com.au |
CNAME → bos1 |
the box | Clients. Their review app, plus /insights for Metabase. |
docs.octopodia.com.au |
CNAME | Cloudflare Pages | You. Deliberately off-box (ADR-0028) — a runbook must survive the outage it is used in. |
Why a client never sees bos1: they get greenhills.app.octopodia.com.au, which
is independent of where it runs. Moving that tenant to dedicated hardware is an
ansible_host change plus a CNAME edit — no new URL, no new certificate for them
to trust, no email announcing a change.
Why monitoring has no name of its own: it sits at console.octopodia.com.au/health,
same audience and same Access policy as the Console. Fewer names to remember.
But uptime checking lives off the box — an on-box monitor cannot tell you the
box is down (OI-048).
Do not confuse three things called "console": BOS Console = your operator app.
/health= infrastructure monitoring. Vultr web console = the browser terminal into the VM, used when SSH is broken (see step 1).
0c. What you're building¶
Before step 1, read the deployment topology — the diagram of containers, networks and ports, and the four boundaries it asserts. For what each component is and what it costs in RAM, see stack and sizing.
Every step below builds one part of it.
| Step | Builds |
|---|---|
3 — hardening |
The host itself: SSH, firewall, fail2ban. No containers yet |
4 — docker |
The runtime and the networks the containers sit on |
5 — caddy |
The front door. Milestone: TLS works on a placeholder page |
6 — core_stack |
Console, brain, registry |
7 — tenant_stack |
One tenant: app, Postgres, n8n, n8n's Postgres |
8 — backup |
The dotted lines to object storage. The restore test is the deliverable |
Steps 6 and 7 are currently blocked — there is no brain or registry image to deploy yet. Steps 1–5 are buildable today; stop at the step 5 milestone.
1. Create the host on Vultr¶
- Deploy a new instance:
- Location: Melbourne (AU).
- OS: Ubuntu 24.04 LTS.
- Size: see the sizing worksheet. Short version: 4 GB for client-zero alone, and budget ~1.4 GB per automation tenant or ~3.4 GB per dashboard tenant thereafter. 2 vCPU is enough to start; no GPU, ever, on this box. Resize on demand — but note that resizing is itself a control-plane migration trigger (ADR-0025 §3).
- Attach at least one of your SSH public keys at creation time. This is the safe path — the key is on the box before it boots, so your very first login is already key-based.
- Note the public IP.
- Find the Vultr web console now (instance → "View Console"). This is your ultimate lifeline: it works even if SSH is completely broken. Knowing where it is turns hardening from scary into routine.
First login test:
ssh root@<ip>(using the key you attached). If that works, you're ready to harden.
2. Point DNS (can run in parallel)¶
In Cloudflare, for octopodia.com.au — per the naming rules in step 0b:
bos1→ A →<ip>. Grey cloud (DNS-only, not proxied) — this is the host record, used for SSH; there is no reason for it to be publicly proxied.console→ CNAME →bos1.octopodia.com.au. Proxied.*.app→ CNAME →bos1.octopodia.com.au. Proxied. This wildcard covers every tenant, so adding a client needs no new DNS record.docs→ CNAME → the Cloudflare Pages target. Not the box.
Do not create per-tenant records by hand — *.app already covers them, and
tenant records are created by the dns role at provisioning time (ADR-0024 §10).
Not needed for hardening — needed before Caddy. Noted here so it isn't forgotten.
3. Harden the host — the two-phase, no-lockout process¶
The golden rule: never break your own access. Verify the new way in works BEFORE you close the old way in. Keep one known-good session open the whole time.
The hardening role is built for this: it validates every SSH change with
sshd -t before reloading (a broken config self-removes and aborts instead of
applying), it reloads SSH rather than restarting (your session survives), and a
ssh_keep_port_22 flag keeps port 22 alive during the transition so you can test
52843 safely first.
flowchart TB
A["<b>PHASE 1</b><br/>ssh_keep_port_22: true<br/>inventory: root @ port 22"]
B["Run host-hardening.yml"]
C["Creates el + lachlan, installs keys<br/>sshd listens on <b>22 AND 52843</b><br/>firewall opens both"]
D{"In a <b>NEW</b> terminal:<br/>ssh -p 52843 el@host → sudo whoami<br/>ssh -p 52843 lachlan@host → sudo whoami<br/><br/>Do BOTH print root?"}
E["<b>STOP — nothing is lost</b><br/>Port 22 is still open<br/>Root session still alive<br/>Fix the keys, re-run Phase 1"]
F["<b>PHASE 2</b><br/>ssh_keep_port_22: false<br/>inventory: el @ port 52843"]
G["Re-run host-hardening.yml"]
H(["Port 22 closed<br/>SSH is 52843 only ✅"])
A --> B --> C --> D
D -->|"either fails"| E
E --> B
D -->|"both succeed"| F --> G --> H
Keep your original session open the entire time
Not until the playbook finishes — until after you have logged in successfully on the new port in a separate terminal. That open session is what turns a mistake into an inconvenience.
If you lose it anyway, the Vultr web console still works (step 1). It is the one access path that does not depend on SSH.
What is open at each stage¶
| Port 22 | Port 52843 | Root login | Passwords | |
|---|---|---|---|---|
| Before | open | closed | allowed | allowed |
| After Phase 1 | still open | open | disabled | disabled |
| After Phase 2 | closed | open | disabled | disabled |
The whole design is that middle row: for a while, both doors are open. That
overlap is the safety margin — do not shorten it by setting
ssh_keep_port_22: false before verifying.
3a. Set your public keys¶
In infra/inventory/group_vars/all.yml:
el_ssh_pubkey: "ssh-ed25519 AAAA... el@laptop"
lachlan_ssh_pubkey: "ssh-ed25519 AAAA... lachlan@laptop"
(Public keys are not secret — plain vars is correct here.)
3b. PHASE 1 — run as root, keep port 22 open¶
Inventory (infra/inventory/hosts.yml, gitignored — copy from the example):
Confirm ssh_keep_port_22: true in roles/hardening/defaults/main.yml, then run:
This creates el + lachlan, installs their keys, opens the firewall
(22 + 52843 + 80 + 443), and hardens sshd to listen on both 22 and 52843.
3c. ⚠️ VERIFY — in a NEW terminal, KEEP the root session open¶
Do not close your existing session. In a second terminal:
ssh -p 52843 el@<ip>
sudo whoami # must print: root
exit
ssh -p 52843 lachlan@<ip>
sudo whoami # must print: root
exit
Both must succeed. If either fails, stop — you still have your root session and port 22 open, so fix the keys/config and re-run phase 1. Nothing is closed yet.
3d. PHASE 2 — switch over and close port 22¶
Only once both users verified. Update the inventory:
Set ssh_keep_port_22: false in roles/hardening/defaults/main.yml, then re-run:
This closes port 22, stops sshd listening on 22, and confirms root + password login are off (they already were).
3e. Confirm the lockdown¶
Host is hardened. ✅
4. What the hardening covers (checklist)¶
- Key-only auth; passwords disabled.
- Root SSH login disabled.
- Per-person users (
el,lachlan), each own key. - SSH on
52843(22 closed after phase 2). - fail2ban enabled on
52843. - Firewall: default-deny inbound; only
52843+ 80 + 443 open. - Unattended security upgrades installed.
5. Next steps (separate roles — do NOT run ahead)¶
The remaining roles are built and run after hardening, in this order. Each is walked through before it's run (see the build-order brief):
docker— Docker + Compose, network model, restart/limits.caddy— reverse proxy + wildcard TLS (needs the Cloudflare token).core_stack— LangGraph, registry, monitoring.tenant_stack— stand up client-zero (Octopodia's own inbox) as tenant one.- Backups → Vultr AU object storage; secrets via Ansible Vault throughout.