Two plays: bootstrap as the provider account (ubuntu, over bootstrap.pem), then everything else as the user that first play just created. The split is forced rather than stylistic -- the second play logs in as that user, so its authorized_keys entry and its NOPASSWD sudoers entry both have to exist before it starts. That is why the key tasks live in the 'user' role and 'wheel' runs in the first play: move either into play 2 and it fails with an auth error or "Missing sudo password". Both accounts and both keys are variables. The inventory sits beside the playbook and is read via ansible.cfg, which Ansible picks up from the current working directory rather than the playbook's, so it has to be run from this directory. bootstrap.pem is gitignored -- it is the provider key for the host.
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.
- Bootstrap the new user — connects as
{{ bootstrap_user }}(ubuntuon Ubuntu cloud images) withbootstrap.pem. Creates{{ user }}, installs itsauthorized_keys, and grants it passwordless sudo. - 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.pemin this directory, mode0600. 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_ed25519and~/.ssh/id_ed25519.pubon 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 workingauthorized_keysentry. That is why the SSH key tasks live in theuserrole rather than incommon, where they would otherwise belong. - The second play escalates with
become, so{{ user }}must already be able to sudo. Thewheelrole 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
userrole authorizes{{ user_key }}.pub, so the control node can always log back in as{{ user }}. Ifuser_keyis left at its default andterminus_keyis 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, andcommonsetsPasswordAuthentication noinsshd_config. The account is reachable by key only. - The
commonrole generates an ed25519 key for the user on the server. That is the server's outbound identity and is unrelated touser_key, which is the control node's key for logging in. - The
userrole deliberately does not set a shell. Doing so would fight thezshrole and flip the shell back on every run. roles/common/files/configis a template despite the extension, and appends the.tanshu.comsuffix to{{ server }}. Adjust it for a host outside that domain.