--- # Common baseline applied to both prod hosts: Docker engine, a non-sudo deploy # service account, SSH hardening, a default-deny firewall, fail2ban, unattended # security upgrades and time sync. Every task is idempotent. - name: Install base packages ansible.builtin.apt: name: - ca-certificates - curl - gnupg - ufw - fail2ban - unattended-upgrades - chrony state: present update_cache: true cache_valid_time: 3600 # --- Docker engine (official repo; trixie is published upstream) --------------- - name: Create apt keyring directory ansible.builtin.file: path: /etc/apt/keyrings state: directory mode: "0755" - name: Install Docker apt GPG key ansible.builtin.get_url: url: https://download.docker.com/linux/debian/gpg dest: /etc/apt/keyrings/docker.asc mode: "0644" - name: Add Docker apt repository ansible.builtin.apt_repository: repo: >- deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable filename: docker state: present - name: Install Docker engine and the compose plugin ansible.builtin.apt: name: - docker-ce - docker-ce-cli - containerd.io - docker-buildx-plugin - docker-compose-plugin state: present update_cache: true - name: Configure the Docker daemon (live-restore + log rotation) ansible.builtin.template: src: daemon.json.j2 dest: /etc/docker/daemon.json mode: "0644" notify: restart docker - name: Enable and start Docker ansible.builtin.service: name: docker enabled: true state: started # --- Deploy service account ---------------------------------------------------- - name: Create the deploy service account ansible.builtin.user: name: "{{ deploy_user }}" groups: docker append: true shell: /bin/bash create_home: true - name: Ensure the deploy .ssh directory ansible.builtin.file: path: "/home/{{ deploy_user }}/.ssh" state: directory owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: "0700" - name: Authorize the CI deploy SSH key (exclusive) ansible.builtin.copy: dest: "/home/{{ deploy_user }}/.ssh/authorized_keys" content: "{{ deploy_ci_pubkey }}\n" owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: "0600" # --- SSH hardening ------------------------------------------------------------- - name: Harden sshd (key-only auth) ansible.builtin.template: src: sshd-hardening.conf.j2 dest: /etc/ssh/sshd_config.d/10-scrabble-hardening.conf mode: "0644" validate: sshd -t -f %s notify: reload sshd # --- Firewall (default deny incoming) ------------------------------------------ # SSH is allowed before the policy flips so enabling ufw never locks us out. - name: Allow SSH through the firewall community.general.ufw: rule: allow name: OpenSSH - name: Default-deny incoming, allow outgoing community.general.ufw: direction: "{{ item.direction }}" policy: "{{ item.policy }}" loop: - { direction: incoming, policy: deny } - { direction: outgoing, policy: allow } - name: Enable the firewall community.general.ufw: state: enabled # --- fail2ban ------------------------------------------------------------------ - name: Configure the fail2ban sshd jail ansible.builtin.template: src: jail.local.j2 dest: /etc/fail2ban/jail.local mode: "0644" notify: restart fail2ban - name: Enable and start fail2ban ansible.builtin.service: name: fail2ban enabled: true state: started # --- Unattended security upgrades + time sync ---------------------------------- - name: Enable unattended upgrades ansible.builtin.copy: dest: /etc/apt/apt.conf.d/20auto-upgrades mode: "0644" content: | APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; - name: Enable and start chrony ansible.builtin.service: name: chrony enabled: true state: started # Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up # across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The # services themselves run in UTC regardless; this is about host-side log correlation. - name: Set the system timezone to UTC community.general.timezone: name: Etc/UTC # --- Swap file (OOM cushion) --------------------------------------------------- # The prod main host is tight (1.9 GiB) and the per-container memory caps # (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit # the kernel OOM-killer (which might pick postgres). A small swap absorbs the # overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix). - name: Check whether the swap file is already active ansible.builtin.command: swapon --show=NAME --noheadings register: swap_active changed_when: false when: swap_size != "0" - name: Allocate the swap file ansible.builtin.command: cmd: "fallocate -l {{ swap_size }} /swapfile" creates: /swapfile when: swap_size != "0" and '/swapfile' not in swap_active.stdout - name: Secure the swap file (0600) ansible.builtin.file: path: /swapfile owner: root group: root mode: "0600" when: swap_size != "0" - name: Format and enable the swap file ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile" when: swap_size != "0" and '/swapfile' not in swap_active.stdout - name: Persist the swap file in /etc/fstab ansible.builtin.lineinfile: path: /etc/fstab line: "/swapfile none swap sw 0 0" regexp: '^/swapfile\s' state: present when: swap_size != "0" - name: Keep swap a cushion, not a hot path (low swappiness) ansible.builtin.copy: dest: /etc/sysctl.d/60-scrabble-swap.conf mode: "0644" content: "vm.swappiness = {{ swap_swappiness }}\n" register: swappiness_conf when: swap_size != "0" - name: Apply swappiness now ansible.builtin.command: sysctl --system changed_when: false when: swap_size != "0" and swappiness_conf is changed # --- Deploy directories -------------------------------------------------------- - name: Create the scrabble base directories ansible.builtin.file: path: "{{ scrabble_base_dir }}/{{ item }}" state: directory owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: "0750" loop: - "" - config - dumps - images # The certs dir holds the reverse-mTLS bot-link keypair, bind-mounted into the gateway (and # backend) which run as the distroless nonroot UID 65532 — not the deploy user. The dir must be # traversable by "other" (0755) or the nonroot process cannot open the 0644 keypair and crash-loops # at startup ("mtls: load server keypair: ... permission denied") — a latent failure that only bites # on a container restart (e.g. a host reboot), not while a long-running container holds the keypair # in memory. The keys themselves are 0644 by design (see the gateway compose); the host is # single-tenant and SSH-access-controlled, so a traversable certs dir adds no meaningful exposure. - name: Create the scrabble certs directory (traversable by the nonroot gateway UID) ansible.builtin.file: path: "{{ scrabble_base_dir }}/certs" state: directory owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: "0755"