first push with basic outline

This commit is contained in:
tolerryan
2026-09-01 15:40:22 +00:00
parent 7ccf851913
commit 121fba9b66
21 changed files with 465 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.vault_pass
*.retry
.ansible/
*.log

View File

@@ -0,0 +1,83 @@
# VPS-ansible
Ansible playbooks to provision and maintain a small fleet (~4) of VPSs.
## Layout
- `inventory.yml` - all hosts, grouped by role (`webservers`, `dbservers`, `misc`)
- `group_vars/all/` - vars shared by every host (`vars.yml` plaintext, `vault.yml` encrypted secrets)
- `group_vars/<group>.yml` or `group_vars/<group>/` - vars for one group of hosts
- `host_vars/<hostname>.yml` - overrides unique to a single VPS
- `roles/common` - baseline: packages, timezone, deploy user, unattended-upgrades
- `roles/security` - SSH hardening, ufw, fail2ban
- `roles/nginx`, `roles/docker`, `roles/postgres` - role-specific stacks, only applied to the relevant group
- `site.yml` - top-level playbook: `common` + `security` run on `all`, then group-specific plays
## First-time setup
```bash
ansible-galaxy collection install -r requirements.yml
```
Create your vault password file (gitignored, never commit it):
```bash
echo "your-vault-password" > .vault_pass
chmod 600 .vault_pass
```
Encrypt the placeholder vault files before you fill in real secrets:
```bash
ansible-vault encrypt group_vars/all/vault.yml
ansible-vault encrypt group_vars/dbservers/vault.yml
```
Edit an encrypted vault file (opens in VSCodium):
```bash
EDITOR="codium --wait" ansible-vault edit group_vars/all/vault.yml
```
Update `inventory.yml` with your real VPS IPs/hostnames, and
`group_vars/all/vars.yml` with your real SSH public key(s).
## Running
Whole fleet:
```bash
ansible-playbook site.yml
```
Just one host:
```bash
ansible-playbook site.yml --limit vps1
```
Just one group:
```bash
ansible-playbook site.yml --limit webservers
```
Check mode (dry run) before applying anything for real:
```bash
ansible-playbook site.yml --check --diff
```
## Bootstrapping a brand-new VPS
The `common` role assumes it can `become: true` (sudo) as `deploy_user`,
which doesn't exist yet on a fresh VPS. For the very first run against a new
box, connect as `root` (or whatever the provider gives you) and override the
remote user on the command line:
```bash
ansible-playbook site.yml --limit vps1 -u root --ask-pass
```
After that first run creates `deploy_user` and installs your SSH key,
subsequent runs will use `deploy_user` automatically via `ansible.cfg`.

13
ansible.cfg Normal file
View File

@@ -0,0 +1,13 @@
[defaults]
inventory = inventory.yml
remote_user = root
host_key_checking = True
retry_files_enabled = False
interpreter_python = auto_silent
roles_path = roles
vault_password_file = .vault_pass
# Speeds up repeated runs against the same small set of hosts
[ssh_connection]
pipelining = True
control_path = ~/.ansible/cp/%%h-%%p-%%r

28
group_vars/all/main.yml Normal file
View File

@@ -0,0 +1,28 @@
---
# Vars for every VPS in the fleet. Keep this file plaintext and readable
# in diffs - actual secret values live in group_vars/all/vault.yml.
timezone: "America/Chicago"
# The non-root user created/used for all future Ansible runs
deploy_user: ansible
deploy_user_shell: /bin/bash
# SSH public keys granted access to deploy_user on every host
deploy_user_ssh_keys:
- "{{ ssh_key_ansible }}"
# Hardening toggles (see roles/security)
ssh_permit_root_login: "no"
ssh_password_authentication: "no"
ssh_port: 25456
ansible_port: 25456
ufw_allowed_tcp_ports:
- "{{ ssh_port }}"
unattended_upgrades_enabled: true
# Example of referencing a vault-held secret from a plaintext var.
# The value itself is defined in group_vars/all/vault.yml as vault_smtp_password.
smtp_password: "{{ vault_smtp_password }}"

45
group_vars/all/vault.yml Normal file
View File

@@ -0,0 +1,45 @@
---
# IMPORTANT: this file must be encrypted before it is committed.
# ansible-vault encrypt group_vars/all/vault.yml
# To edit later:
# EDITOR="codium --wait" ansible-vault edit group_vars/all/vault.yml
#
# Convention: every var in here is prefixed with vault_ so it's obvious
# in vars.yml which values come from the vault.
vault_smtp_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
61323135353935366635363664393830363336363636326334643338383762653737656563663338
3733363334363066383532303565613136396130646364630a343738323264616137313165633338
61306662313264653136363836643331353437366233626432386564633237653537646335356631
6163623832336234650a316136346232326331663931313762303964303632663866633031633038
3765
ssh_port: !vault |
$ANSIBLE_VAULT;1.1;AES256
62383936303362623464643165643162643961656466303662393263666634356530366362656135
3963383332363835653838383637366639666532326530610a323635373236303238653138336362
33306663663835343063376434363231313837363531353237616239393630633061643662663239
3038396433376265320a623063646363303166373338666531353937336131383566343865336138
6338
ssh_key_root: !vault |
$ANSIBLE_VAULT;1.1;AES256
39383066306265616139386537326635363864623261623966666237363863386233373065626239
3838373338326535613338386661363366633765643839380a323961303466316633396431383836
61353265333638643962383062343535666133353162613761323461336464623236623737666330
3936343765366233350a336533346662356233663834336265623137326638623038346631393365
36323130333430353632306330343339663962393039653831343064336261396565613530663261
32376262376462393030666431353538316337323439333764643566353565373437383265643866
39376162636264306465313138316164376533646437626361626532633265303362343662623537
32323562303062386464666464666162373038613639366135633563393437613438323632626665
3838
ssh_key_ansible: !vault |
$ANSIBLE_VAULT;1.1;AES256
63323264313237376633336661303737616439333765306239633836323563663433323334633665
6135393039316536336636333463656234346264333632310a633134393036626132623736313834
33333430353533356532393038393936383138383364613036363764323065373838316463643630
3163346264303161390a366539343365626131623435376264373236653632356466663365346631
36613734626532656330363564303833623661656336323539373666613762373333616138306166
30376330386639663938633331636264646364333438313961303063363135666466323163383831
64646366656263346435353138626633343430626166343761663131396535633633616462613534
34626438646634393266303034353362363365313366306465633334663631353639323739656432
3164

View File

@@ -0,0 +1,10 @@
---
postgres_version: 16
postgres_databases:
- name: app_production
postgres_users:
- name: app_user
password: "{{ vault_postgres_app_user_password }}"
ufw_allowed_tcp_ports_extra:
- 5432

View File

@@ -0,0 +1,9 @@
---
# ansible-vault encrypt group_vars/dbservers/vault.yml
vault_postgres_app_user_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
33303539306334343565623232393134626264383662376531646339636433373235623733386239
3139663334353234346436616163656366616364333636650a373265366666666163393263313433
35366662323130363332373263323264346633633635346331323534376439666363383863393830
6565336230636638660a323838643534383664306631643933633931383461633533663535346635
3133

View File

@@ -0,0 +1,9 @@
---
nginx_sites:
- name: example.com
server_name: "example.com www.example.com"
root: /var/www/example.com
ufw_allowed_tcp_ports_extra:
- 80
- 443

4
host_vars/vps1.yml Normal file
View File

@@ -0,0 +1,4 @@
---
# Anything here overrides group_vars/all.yml and group_vars/webservers.yml
# for this host only. Example: vps1 runs SSH on a nonstandard port.

6
host_vars/vps4.yml Normal file
View File

@@ -0,0 +1,6 @@
---
# vps4 is a standalone "misc" box - not in webservers or dbservers,
# so it only ever gets the common + security roles from site.yml.
#extra_packages:
# - restic

23
inventory.yml Normal file
View File

@@ -0,0 +1,23 @@
---
all:
vars:
ansible_python_interpreter: /usr/bin/python3
children:
webservers:
hosts:
#vps1:
# ansible_host: 203.0.113.10
#vps2:
# ansible_host: 203.0.113.11
dbservers:
hosts:
#vps3:
# ansible_host: 203.0.113.12
misc:
hosts:
dal14:
ansible_host: 192.3.228.229

5
requirements.yml Normal file
View File

@@ -0,0 +1,5 @@
---
collections:
- name: community.general
- name: ansible.posix
- name: community.postgresql

View File

@@ -0,0 +1,11 @@
---
common_base_packages:
- curl
- wget
- vim
- btop
- git
- unzip
- ca-certificates
- gnupg
- restic

View File

@@ -0,0 +1,56 @@
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install base packages
ansible.builtin.apt:
name: "{{ common_base_packages }}"
state: present
- name: Set timezone
community.general.timezone:
name: "{{ timezone }}"
- name: Ensure deploy user exists
ansible.builtin.user:
name: "{{ deploy_user }}"
shell: "{{ deploy_user_shell }}"
groups: sudo
append: true
create_home: true
- name: Allow deploy user passwordless sudo
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ deploy_user }}"
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: "visudo -cf %s"
- name: Install SSH keys for deploy user
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ item }}"
loop: "{{ deploy_user_ssh_keys }}"
- name: Install extra host-specific packages (if defined)
ansible.builtin.apt:
name: "{{ extra_packages }}"
state: present
when: extra_packages is defined
- name: Enable unattended-upgrades
ansible.builtin.apt:
name: unattended-upgrades
state: present
when: unattended_upgrades_enabled | bool
- name: Configure unattended-upgrades to auto-reboot if needed
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/52unattended-upgrades-local
content: |
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
mode: "0644"
when: unattended_upgrades_enabled | bool

View File

@@ -0,0 +1,26 @@
---
- name: Check if docker is already installed
ansible.builtin.command: docker --version
register: docker_check
changed_when: false
failed_when: false
- name: Install docker via convenience script
ansible.builtin.shell: |
set -o pipefail
curl -fsSL https://get.docker.com | sh
args:
executable: /bin/bash
when: docker_check.rc != 0
- name: Add deploy user to docker group
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: docker
append: true
- name: Ensure docker is running and enabled
ansible.builtin.service:
name: docker
state: started
enabled: true

View File

@@ -0,0 +1,21 @@
---
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
# TODO: template out vhosts from the `nginx_sites` var defined in
# group_vars/webservers.yml, e.g.:
#
# - name: Deploy vhost configs
# ansible.builtin.template:
# src: vhost.conf.j2
# dest: "/etc/nginx/sites-available/{{ item.name }}"
# loop: "{{ nginx_sites }}"
# notify: Reload nginx

View File

@@ -0,0 +1,27 @@
---
- name: Install postgresql
ansible.builtin.apt:
name:
- "postgresql-{{ postgres_version }}"
- python3-psycopg2
state: present
- name: Ensure postgresql is running and enabled
ansible.builtin.service:
name: postgresql
state: started
enabled: true
- name: Create databases
become_user: postgres
community.postgresql.postgresql_db:
name: "{{ item.name }}"
loop: "{{ postgres_databases }}"
- name: Create users
become_user: postgres
community.postgresql.postgresql_user:
name: "{{ item.name }}"
password: "{{ item.password }}"
loop: "{{ postgres_users }}"
no_log: true

View File

@@ -0,0 +1,2 @@
---
ufw_allowed_tcp_ports_extra: []

View File

@@ -0,0 +1,5 @@
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted

View File

@@ -0,0 +1,58 @@
---
- name: Install ufw and fail2ban
ansible.builtin.apt:
name:
- ufw
- fail2ban
state: present
- name: Harden sshd_config - PermitRootLogin
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PermitRootLogin"
line: "PermitRootLogin {{ ssh_permit_root_login }}"
notify: Restart sshd
- name: Harden sshd_config - PasswordAuthentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication {{ ssh_password_authentication }}"
notify: Restart sshd
- name: Set sshd Port
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?Port"
line: "Port {{ ssh_port }}"
notify: Restart sshd
- name: Reset ufw defaults (deny incoming, allow outgoing)
community.general.ufw:
state: reset
changed_when: false
- name: Set ufw default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
- name: Allow required TCP ports through ufw
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ (ufw_allowed_tcp_ports + ufw_allowed_tcp_ports_extra) | unique }}"
- name: Enable ufw
community.general.ufw:
state: enabled
- name: Ensure fail2ban is running and enabled
ansible.builtin.service:
name: fail2ban
state: started
enabled: true

20
site.yml Normal file
View File

@@ -0,0 +1,20 @@
---
- name: Baseline configuration for every VPS
hosts: all
become: true
roles:
- common
- security
- name: Web servers
hosts: webservers
become: true
roles:
- docker
- nginx
- name: Database servers
hosts: dbservers
become: true
roles:
- postgres