first push with basic outline
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.vault_pass
|
||||||
|
*.retry
|
||||||
|
.ansible/
|
||||||
|
*.log
|
||||||
83
README.md
83
README.md
@@ -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
13
ansible.cfg
Normal 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
28
group_vars/all/main.yml
Normal 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
45
group_vars/all/vault.yml
Normal 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
|
||||||
10
group_vars/dbservers/vars.yml
Normal file
10
group_vars/dbservers/vars.yml
Normal 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
|
||||||
9
group_vars/dbservers/vault.yml
Normal file
9
group_vars/dbservers/vault.yml
Normal 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
|
||||||
9
group_vars/webservers.yml
Normal file
9
group_vars/webservers.yml
Normal 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
4
host_vars/vps1.yml
Normal 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
6
host_vars/vps4.yml
Normal 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
23
inventory.yml
Normal 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
5
requirements.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
collections:
|
||||||
|
- name: community.general
|
||||||
|
- name: ansible.posix
|
||||||
|
- name: community.postgresql
|
||||||
11
roles/common/defaults/main.yml
Normal file
11
roles/common/defaults/main.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
common_base_packages:
|
||||||
|
- curl
|
||||||
|
- wget
|
||||||
|
- vim
|
||||||
|
- btop
|
||||||
|
- git
|
||||||
|
- unzip
|
||||||
|
- ca-certificates
|
||||||
|
- gnupg
|
||||||
|
- restic
|
||||||
56
roles/common/tasks/main.yml
Normal file
56
roles/common/tasks/main.yml
Normal 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
|
||||||
26
roles/docker/tasks/main.yml
Normal file
26
roles/docker/tasks/main.yml
Normal 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
|
||||||
21
roles/nginx/tasks/main.yml
Normal file
21
roles/nginx/tasks/main.yml
Normal 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
|
||||||
27
roles/postgres/tasks/main.yml
Normal file
27
roles/postgres/tasks/main.yml
Normal 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
|
||||||
2
roles/security/defaults/main.yml
Normal file
2
roles/security/defaults/main.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
ufw_allowed_tcp_ports_extra: []
|
||||||
5
roles/security/handlers/main.yml
Normal file
5
roles/security/handlers/main.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Restart sshd
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: ssh
|
||||||
|
state: restarted
|
||||||
58
roles/security/tasks/main.yml
Normal file
58
roles/security/tasks/main.yml
Normal 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
|
||||||
Reference in New Issue
Block a user