From 121fba9b660f96f295ad259f2926b06e93814476 Mon Sep 17 00:00:00 2001 From: tolerryan Date: Tue, 1 Sep 2026 15:40:22 +0000 Subject: [PATCH] first push with basic outline --- .gitignore | 4 ++ README.md | 83 ++++++++++++++++++++++++++++++++ ansible.cfg | 13 +++++ group_vars/all/main.yml | 28 +++++++++++ group_vars/all/vault.yml | 45 +++++++++++++++++ group_vars/dbservers/vars.yml | 10 ++++ group_vars/dbservers/vault.yml | 9 ++++ group_vars/webservers.yml | 9 ++++ host_vars/vps1.yml | 4 ++ host_vars/vps4.yml | 6 +++ inventory.yml | 23 +++++++++ requirements.yml | 5 ++ roles/common/defaults/main.yml | 11 +++++ roles/common/tasks/main.yml | 56 +++++++++++++++++++++ roles/docker/tasks/main.yml | 26 ++++++++++ roles/nginx/tasks/main.yml | 21 ++++++++ roles/postgres/tasks/main.yml | 27 +++++++++++ roles/security/defaults/main.yml | 2 + roles/security/handlers/main.yml | 5 ++ roles/security/tasks/main.yml | 58 ++++++++++++++++++++++ site.yml | 20 ++++++++ 21 files changed, 465 insertions(+) create mode 100644 .gitignore create mode 100644 ansible.cfg create mode 100644 group_vars/all/main.yml create mode 100644 group_vars/all/vault.yml create mode 100644 group_vars/dbservers/vars.yml create mode 100644 group_vars/dbservers/vault.yml create mode 100644 group_vars/webservers.yml create mode 100644 host_vars/vps1.yml create mode 100644 host_vars/vps4.yml create mode 100644 inventory.yml create mode 100644 requirements.yml create mode 100644 roles/common/defaults/main.yml create mode 100644 roles/common/tasks/main.yml create mode 100644 roles/docker/tasks/main.yml create mode 100644 roles/nginx/tasks/main.yml create mode 100644 roles/postgres/tasks/main.yml create mode 100644 roles/security/defaults/main.yml create mode 100644 roles/security/handlers/main.yml create mode 100644 roles/security/tasks/main.yml create mode 100644 site.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed5753b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vault_pass +*.retry +.ansible/ +*.log diff --git a/README.md b/README.md index e69de29..8266b8d 100644 --- a/README.md +++ b/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/.yml` or `group_vars//` - vars for one group of hosts +- `host_vars/.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`. diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..1bb70de --- /dev/null +++ b/ansible.cfg @@ -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 diff --git a/group_vars/all/main.yml b/group_vars/all/main.yml new file mode 100644 index 0000000..46c6f43 --- /dev/null +++ b/group_vars/all/main.yml @@ -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 }}" diff --git a/group_vars/all/vault.yml b/group_vars/all/vault.yml new file mode 100644 index 0000000..501d708 --- /dev/null +++ b/group_vars/all/vault.yml @@ -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 \ No newline at end of file diff --git a/group_vars/dbservers/vars.yml b/group_vars/dbservers/vars.yml new file mode 100644 index 0000000..8e97c1c --- /dev/null +++ b/group_vars/dbservers/vars.yml @@ -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 diff --git a/group_vars/dbservers/vault.yml b/group_vars/dbservers/vault.yml new file mode 100644 index 0000000..46708c7 --- /dev/null +++ b/group_vars/dbservers/vault.yml @@ -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 diff --git a/group_vars/webservers.yml b/group_vars/webservers.yml new file mode 100644 index 0000000..a996fe4 --- /dev/null +++ b/group_vars/webservers.yml @@ -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 diff --git a/host_vars/vps1.yml b/host_vars/vps1.yml new file mode 100644 index 0000000..553b0ea --- /dev/null +++ b/host_vars/vps1.yml @@ -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. + diff --git a/host_vars/vps4.yml b/host_vars/vps4.yml new file mode 100644 index 0000000..3d9a261 --- /dev/null +++ b/host_vars/vps4.yml @@ -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 + diff --git a/inventory.yml b/inventory.yml new file mode 100644 index 0000000..23c89ea --- /dev/null +++ b/inventory.yml @@ -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 diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..57f59fa --- /dev/null +++ b/requirements.yml @@ -0,0 +1,5 @@ +--- +collections: + - name: community.general + - name: ansible.posix + - name: community.postgresql diff --git a/roles/common/defaults/main.yml b/roles/common/defaults/main.yml new file mode 100644 index 0000000..8684e26 --- /dev/null +++ b/roles/common/defaults/main.yml @@ -0,0 +1,11 @@ +--- +common_base_packages: + - curl + - wget + - vim + - btop + - git + - unzip + - ca-certificates + - gnupg + - restic \ No newline at end of file diff --git a/roles/common/tasks/main.yml b/roles/common/tasks/main.yml new file mode 100644 index 0000000..fa25150 --- /dev/null +++ b/roles/common/tasks/main.yml @@ -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 diff --git a/roles/docker/tasks/main.yml b/roles/docker/tasks/main.yml new file mode 100644 index 0000000..b15c1dc --- /dev/null +++ b/roles/docker/tasks/main.yml @@ -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 diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml new file mode 100644 index 0000000..420efbc --- /dev/null +++ b/roles/nginx/tasks/main.yml @@ -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 diff --git a/roles/postgres/tasks/main.yml b/roles/postgres/tasks/main.yml new file mode 100644 index 0000000..086dc00 --- /dev/null +++ b/roles/postgres/tasks/main.yml @@ -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 diff --git a/roles/security/defaults/main.yml b/roles/security/defaults/main.yml new file mode 100644 index 0000000..b479dc4 --- /dev/null +++ b/roles/security/defaults/main.yml @@ -0,0 +1,2 @@ +--- +ufw_allowed_tcp_ports_extra: [] diff --git a/roles/security/handlers/main.yml b/roles/security/handlers/main.yml new file mode 100644 index 0000000..cad74d8 --- /dev/null +++ b/roles/security/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart sshd + ansible.builtin.service: + name: ssh + state: restarted diff --git a/roles/security/tasks/main.yml b/roles/security/tasks/main.yml new file mode 100644 index 0000000..9557ef0 --- /dev/null +++ b/roles/security/tasks/main.yml @@ -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 diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..21a6460 --- /dev/null +++ b/site.yml @@ -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