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, whether a second shared box or a dedicated one for a client. Nothing here is specific to the first.
The inventory moved, 16 October 2026
The live inventory is now its own private repository, octopodia-abos-inventory, cloned beside the code
(ADR-0042). Paths below written as
infra/inventory/... or inventory/... are now in that repository, and secrets no longer go in
group_vars/all/vault.yml: a host's go in host_vars/<host>/vault.yml (vault ID core), a tenant's in
tenants/<slug>/vault.yml (the tenant's own vault ID), each value added with ansible-vault encrypt_string.
See the inventory repository runbook.
Once it is running: host maintenance · adding a tenant
Read Known traps first, or at least know it exists
Ubuntu 24.04, Docker, Caddy and Tailscale 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 three-phase 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), ADR-0031 (Tailscale admin access). This runbook executes them, it does not re-decide them. (ADR-0021 to ADR-0027 superseded ADR-0014, which bundled all of it 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 does not 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 organisation and shared collection created, both partners Owners (ADR-0027 §6, OI-015). A Free organisation is sufficient at two users. 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. See Generating your SSH key and send that
page to anyone who needs to make one. You need the public keys
(
~/.ssh/id_ed25519.pub), which go intoinfra/inventory/group_vars/all/main.yml. Private keys are never shared (ADR-0021 §4): a shared key cannot be revoked for one person without locking out both, and it destroys attribution. -
corevault password created and put in Bitwarden immediately. Per-tenant vault passwords come later, at tenant provisioning (ADR-0027 §3). - The control machine,
octo-ctl, with Ansible installed via pipx and the repo cloned frommainonly: playbooks that touch real hosts run from what has been merged, never from work in progress. Windows is not a supported control node. The vault password lives at~/.config/abos/vault_pass(mode600), withANSIBLE_VAULT_PASSWORD_FILEpointing at it, so playbooks need no--vault-idflag there. - Tailscale auth key: pre-authorised, reusable, carrying
tag:server(OI-057). Needed for phase 2. Tagged nodes are owned by the tailnet rather than a person's account, so a host does not vanish when someone's account changes. Machine credential, so it goes in Ansible Vault undercore, not Bitwarden (ADR-0027 §7). You may need to definetag:serverin the tailnet ACL first (OI-058). - 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. Also Ansible Vault undercore. Not needed for hardening, needed for Caddy. - Provider root logins in Bitwarden: Vultr, Cloudflare, Micron21 (registrar), Microsoft 365, GitHub org owner, Railway, each with its MFA recovery codes.
Not needed yet: Vultr object storage and the age backup keypair (OI-036, OI-054).
Those arrive with the backup role, several steps later.
Locked values (store the port in Bitwarden 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, and the things running on it get theirs. That is what makes a client migration invisible later (OI-047).
Host names are the machine itself. Ops vocabulary only, and a client never sees one.
| Situation | Name |
|---|---|
| Shared host | bos1, then bos2, … |
| Dedicated host for a client | {tenant}-bos1, for example greenhills-bos1 |
Uses the tenant slug from ADR-0022, which is immutable, so there is one
vocabulary rather than 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. 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 |
Note that under ADR-0031 the host record is no longer the SSH target. Admin
access is over Tailscale, and bos1.octopodia.com.au exists so that the service
CNAMEs have something stable to point at.
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,
because an on-box monitor cannot tell you the box is down (OI-048).
Do not confuse three things called "console": BOS Console is your operator app.
/healthis infrastructure monitoring. Vultr web console is the browser terminal into the VM, used when SSH is broken (see step 1).
0c. What you are 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 |
3d, tailscale |
Admin access over the tailnet, before public SSH closes |
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 to 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, then budget around 1.4 GB per automation tenant or 3.4 GB per dashboard tenant. 2 vCPU is enough to start, and 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. The key is then 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, then "View Console"). This is your ultimate lifeline: it works even if SSH and Tailscale are both 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 are 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.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).
Once the host exists and has an IP, the dns role can do steps 1 to 3 for you:
Not needed for hardening, needed before Caddy. Noted here so it is not forgotten.
3. Harden the host: the three-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.
Administrative access ends up on Tailscale with no public SSH port at all (ADR-0031). Getting there safely takes three phases: public SSH stays open while Tailscale is installed and proven, and only closes once both admins have used it.
The hardening role is built for this. It validates every SSH change with
sshd -t before reloading, so a broken config removes itself and aborts rather
than applying. It reloads SSH rather than restarting, so your session survives.
And ssh_public_access controls the phase.
flowchart TB
A["<b>PHASE 1</b><br/>ssh_public_access: true<br/>inventory: root @ port 22"]
B["Run host-hardening.yml"]
C["Creates el + lachlan, installs keys<br/>sshd on <b>22 AND 52843</b><br/>public SSH still open"]
D{"NEW terminal, both admins:<br/>ssh -p 52843 el@<public-ip><br/>ssh -p 52843 lachlan@<public-ip><br/><br/>Both print root?"}
E["<b>STOP. Nothing is lost.</b><br/>Public SSH still open,<br/>root session still alive.<br/>Fix keys, re-run Phase 1"]
F["<b>PHASE 2</b><br/>Run tailnet.yml"]
G["Host joins the tailnet.<br/><b>Disable key expiry</b> in the<br/>Tailscale admin console"]
H{"Both admins over Tailscale:<br/>ssh -p 52843 el@<tailscale-ip><br/>ssh -p 52843 lachlan@<tailscale-ip><br/><br/>Both print root?"}
I["<b>STOP.</b> Public SSH is still<br/>open. Fix the tailnet, retry"]
J["<b>PHASE 3</b><br/>ssh_public_access: false<br/>Re-run host-hardening.yml"]
K(["No public SSH port.<br/>Admin access via Tailscale only ✅"])
A --> B --> C --> D
D -->|"either fails"| E
E --> B
D -->|"both succeed"| F --> G --> H
H -->|"either fails"| I
I --> F
H -->|"both succeed"| J --> K
Keep your original session open the entire time
Not until the playbook finishes, but until after you have logged in successfully by the new route 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 depends on neither SSH nor Tailscale.
What is open at each stage¶
| Public 22 | Public 52843 | Tailscale 52843 | Root login | Passwords | |
|---|---|---|---|---|---|
| Before | open | closed | n/a | allowed | allowed |
| After Phase 1 | still open | open | n/a | disabled | disabled |
| After Phase 2 | still open | still open | open | disabled | disabled |
| After Phase 3 | closed | closed | open | disabled | disabled |
The design is those middle rows: for a while both doors are open. That
overlap is the safety margin. Do not shorten it by setting
ssh_public_access: false before verifying Tailscale.
3a. Set both public keys¶
In infra/inventory/group_vars/all/main.yml, replacing the placeholders:
admin_users:
- name: el
key: "ssh-ed25519 AAAA... el@laptop"
- name: lachlan
key: "ssh-ed25519 AAAA... lachlan@laptop"
Public keys are not secret, so plain vars is correct. See Generating your SSH key.
3b. PHASE 1: harden, with public SSH still open¶
Inventory (infra/inventory/hosts.yml, gitignored, copy from the example):
all:
hosts:
bos1:
ansible_host: <public-ip>
dns_host_ip: <public-ip>
ansible_user: root
ansible_port: 22
ssh_public_access defaults to true in roles/hardening/defaults/main.yml,
which is phase 1. Leave the default alone; phase 3 is set per host in the
inventory (step 3f). Then:
Creates el and lachlan, installs their keys, hardens sshd to listen on both
22 and 52843, and opens the firewall.
3c. VERIFY, in a NEW terminal, keeping the root session open¶
ssh -p 52843 el@<public-ip>
sudo whoami # must print: root
exit
ssh -p 52843 lachlan@<public-ip>
sudo whoami # must print: root
exit
Both must succeed. If either fails, stop. You still have the root session and public SSH, so fix the keys and re-run phase 1. Nothing is closed.
3d. PHASE 2: join the tailnet¶
Requires a pre-authorised, reusable Tailscale auth key carrying tag:server, in
the core vault (OI-057).
Switch the inventory first. Phase 1 disabled root login, so Ansible can no
longer connect as root on 22. Connect as el on 52843, still over the
public address (see inventory/hosts.phase2.example.yml):
The role prints the host's Tailscale address when it finishes.
Disable key expiry before going further
Tailscale admin console → Machines → bos1 → Disable key expiry.
Node keys expire by default, typically after 180 days. An expired key silently drops the host off the tailnet, and after phase 3 there is no public SSH port to fall back to. It fails months after the change that caused it, when nobody connects the two.
The Ansible role cannot set this: it is a tailnet-side property, not a host-side one. See known traps §11 and OI-056.
3e. VERIFY over Tailscale¶
ssh -p 52843 el@<tailscale-ip>
sudo whoami
exit
ssh -p 52843 lachlan@<tailscale-ip>
sudo whoami
exit
Both must succeed, from both people's machines. Public SSH is still open, so a failure here costs nothing.
3f. PHASE 3: close public SSH¶
Only once both verifications have passed. Update this host's inventory entry
to reach it over Tailscale, and set ssh_public_access: false on the host
itself:
ansible_host: <tailscale-ip>
dns_host_ip: <public-ip> # DNS record stays public
ansible_user: el
ansible_port: 52843
ssh_public_access: false # this host only
Never change the role's default instead. The default stays true, so every
new host starts in phase 1. Setting false per host means each host's phase
lives with that host, and closing SSH on one can never accidentally close it on
another that has not yet joined the tailnet. Then re-run:
3g. Confirm the lockdown¶
Host is hardened. The only ports open to the internet are 80 and 443.
4. What the hardening covers¶
- Key-only authentication, passwords disabled
- Root SSH login disabled
- Per-person users (
el,lachlan), each with their own key - SSH on
52843, Tailscale interface only - No public SSH port at all (ADR-0031)
- Public exposure limited to 80 and 443
- fail2ban enabled (near-redundant now, kept for phases 1 and 2)
- Unattended OS security upgrades
- Tailscale key expiry disabled in the admin console (manual, OI-056)
5. Next steps: separate roles, do not run ahead¶
docker: Docker and Compose, the network model, restart policy and limitscaddy: reverse proxy and wildcard TLS. Needs the Cloudflare token. Milestone: the placeholder page over a valid certificatecore_stack: brain, registry, Consoletenant_stack: client-zero, Octopodia's own inbox, as tenant zerobackup: encrypted to AU object storage. The restore test is the deliverable
Steps 3 and 4 are blocked: no brain or registry image exists yet.