Files
playbooks/prepare-server
2026-10-18 16:08:07 +00:00
..
2026-10-15 21:24:32 +05:30
2026-10-15 21:24:32 +05:30
2026-10-15 21:24:32 +05:30
2026-10-15 21:24:32 +05:30
2026-10-18 16:08:07 +00:00
2026-10-15 21:24:32 +05:30

Prepare server

Takes a freshly provisioned Ubuntu host that you can only reach as the provider's bootstrap account, creates the user you actually want, and configures that account. When it finishes you log in as {{ user }}, not as ubuntu.

This should be the first playbook you run on a new server. The service playbooks take a -u [remote user] and expect that account to already exist — this is the playbook that creates it.

How it works

Two plays, because the second one logs in as the user the first one creates.

  1. Bootstrap the new user — connects as {{ bootstrap_user }} (ubuntu on Ubuntu cloud images) with bootstrap.pem. Creates {{ user }}, installs its authorized_keys, and grants it passwordless sudo.
  2. Configure the server as the new user — connects as {{ user }} with {{ user_key }}. Hardens sshd, installs the user's own SSH config and key, and sets up zsh.

Every task after the first play is done as {{ user }}, which is the point: the bootstrap account is used once and then never again.

Layout

prepare-server/
├── ansible.cfg            # read from the CWD; points at ./inventory
├── inventory              # the hosts, so -i is not needed
├── bootstrap.pem          # provider key: gitignored, must be mode 0600
├── playbook.yml
├── vars/default.yml
└── roles/
    ├── user/     # play 1 — create the account, install authorized_keys
    ├── wheel/    # play 1 — wheel group + passwordless sudo
    ├── common/   # play 2 — sshd hardening, .ssh config, the user's own key
    └── zsh/      # play 2 — zsh, oh-my-zsh, autosuggestions, syntax highlighting

Settings

Variable Purpose
server The host name written into the .ssh/config on the server. It refers to a specific server, so it is not used for key comments — those use inventory_hostname, and the two may legitimately differ.
user The account to create, and the account every task after the first play runs as.
user_key Private key on the control node used to log in as user. Its .pub sibling must exist — the user role authorizes it.
bootstrap_user The provider account that exists on a fresh host. ubuntu on Ubuntu cloud images.
bootstrap_key The provider key that reaches that account: bootstrap.pem next to the playbook.
terminus_key, clair_key, anjin_key Additional public keys authorized to log in as user.

Prerequisites

  • A fresh host reachable as {{ bootstrap_user }}, with passwordless sudo — cloud images ship it that way. The playbook escalates immediately, so a bootstrap account that needs a sudo password will not work unattended.
  • bootstrap.pem in this directory, mode 0600. OpenSSH refuses to use a private key that is group- or world-readable, and fails with "UNPROTECTED PRIVATE KEY FILE" rather than anything resembling a permissions hint. The file is gitignored — do not commit it.
  • ~/.ssh/id_ed25519 and ~/.ssh/id_ed25519.pub on the control node.

Running

cd prepare-server
ansible-playbook playbook.yml

The cd is load-bearing. Ansible reads ansible.cfg from the current working directory, not from the playbook's directory, so ansible.cfg — and the inventory it names — only apply when you run from in here. Running ansible-playbook prepare-server/playbook.yml from the repository root ignores both and silently falls back to /etc/ansible/hosts, which happens to list clair as well, so it looks like it worked.

Ansible also has no by-name discovery for inventory files: inventory is read because ansible.cfg names it, not because of what it is called. Add -l <host> once the inventory holds more than one.

-u is deliberately not needed: each play sets remote_user itself, so the bootstrap account is used for the first play and the new user for the second.

Why sudo and authorized_keys are set up in the first play

Both have to happen before the second play connects, not because they logically belong to bootstrapping:

  • The second play logs in as {{ user }}, so that account must already have a working authorized_keys entry. That is why the SSH key tasks live in the user role rather than in common, where they would otherwise belong.
  • The second play escalates with become, so {{ user }} must already be able to sudo. The wheel role writes /etc/sudoers.d/wheel, which means it has to run while we still have an account that can escalate — i.e. during the bootstrap play. Left until the second play it fails with "Missing sudo password".

Notes

  • The user role authorizes {{ user_key }}.pub, so the control node can always log back in as {{ user }}. If user_key is left at its default and terminus_key is that same key, the entry is already there and the task is a no-op.
  • No password is set on {{ user }}, which leaves it locked for password logins, and common sets PasswordAuthentication no in sshd_config. The account is reachable by key only.
  • The common role generates an ed25519 key for the user on the server. That is the server's outbound identity and is unrelated to user_key, which is the control node's key for logging in.
  • The user role deliberately does not set a shell. Doing so would fight the zsh role and flip the shell back on every run.
  • roles/common/files/config is a template despite the extension, and appends the .tanshu.com suffix to {{ server }}. Adjust it for a host outside that domain.