--- - name: Configure a Raspberry Pi as the LAN's DNS server hosts: all vars: # Where your config repo is cloned on the Pi. app_dir: "/home/{{ ansible_user }}/{{ config_repo.split('/')[1] }}" timezone: Etc/UTC # If an unattended security upgrade needs a reboot, it happens at this # local time. Pick a quiet hour: the Pi is the network's only DNS server. reboot_time: "04:00" ntp_enabled: true ntp_timezone: "{{ timezone }}" ntp_servers: - time.cloudflare.com docker_users: - "{{ ansible_user }}" docker_install_compose_plugin: true security_ssh_allowed_users: - "{{ ansible_user }}" security_sudoers_passwordless: - "{{ ansible_user }}" security_autoupdate_reboot: true security_autoupdate_reboot_time: "{{ reboot_time }}" pip_package: python3-pip pip_install_packages: - name: docker pre_tasks: # Set github_user and config_repo in your inventory. There's deliberately # no default: a default username would authorize someone else's SSH keys. - name: Ensure required variables are set ansible.builtin.assert: that: - github_user is defined and github_user | length > 0 - config_repo is defined and config_repo | length > 0 fail_msg: >- Set github_user (whose public SSH keys to authorize) and config_repo (your private config repo, as OWNER/NAME) in hosts.yml. See hosts.example.yml. quiet: true # Secrets arrive as environment variables (see playbook.env.example). # lookup('env') turns a missing one into "", which would write an empty # token or password, so stop before touching the Pi instead. - name: Ensure every secret in playbook.env.example is set ansible.builtin.assert: that: lookup('env', item) | length > 0 fail_msg: "{{ item }} is empty. See playbook.env.example." quiet: true # The names come from playbook.env.example itself, so the two can't drift. loop: "{{ lookup('file', 'playbook.env.example').splitlines() | select('match', '^[A-Z_]+=') | map('regex_replace', '=.*', '') | list }}" delegate_to: localhost run_once: true # noqa: run-once[task] # Before the security role below turns off SSH password login, so a # first run over a password can't lock you out halfway through. - name: Ensure SSH keys are authorized ansible.posix.authorized_key: user: "{{ ansible_user }}" state: present key: "https://github.com/{{ github_user }}.keys" roles: # Disables SSH password auth, sets up unattended upgrades and fail2ban. - role: geerlingguy.security become: true - role: geerlingguy.ntp become: true # The Docker SDK for Python, which the community.docker modules need. - role: geerlingguy.pip become: true # Docker from Docker's own apt repo for this Debian release and CPU, # with the compose plugin. - role: geerlingguy.docker become: true tasks: # A deploy key lets the Pi pull your private config repo. - name: Ensure deploy key is present community.crypto.openssh_keypair: path: ~/.ssh/id_github type: ed25519 register: deploy_key - name: Ensure deploy key is authorized community.general.github_deploy_key: key: "{{ deploy_key.public_key }}" name: "{{ inventory_hostname }}" state: present owner: "{{ config_repo.split('/')[0] }}" repo: "{{ config_repo.split('/')[1] }}" token: "{{ lookup('env', 'PI_GITHUB_TOKEN') }}" - name: Clone config repo ansible.builtin.git: repo: "git@github.com:{{ config_repo }}.git" dest: "{{ app_dir }}" version: main key_file: ~/.ssh/id_github accept_hostkey: true - name: Install ufw become: true ansible.builtin.apt: name: ufw state: present # Docker-published ports (53, 80, 443) bypass ufw, so the limits that # matter for those are AdGuard's allowed_clients and the Caddyfile's # remote_ip matcher. ufw still covers SSH and anything else on the host. - name: Rate-limit SSH become: true community.general.ufw: rule: limit port: ssh proto: tcp - name: Allow SSH become: true community.general.ufw: rule: allow app: OpenSSH - name: Enable ufw and default to deny become: true community.general.ufw: state: enabled default: deny - name: Set Caddy secret no_log: true ansible.builtin.copy: dest: "{{ app_dir }}/.caddy.env" content: "CLOUDFLARE_API_TOKEN={{ lookup('env', 'PI_CLOUDFLARE_TOKEN') }}\n" mode: "0600" # First run only (force: false), so settings changed in the AdGuard UI stick. - name: Create AdGuard config from the example become: true ansible.builtin.copy: src: "{{ app_dir }}/adguard-conf/AdGuardHome.example.yaml" dest: "{{ app_dir }}/adguard-conf/AdGuardHome.yaml" remote_src: true force: false mode: "0600" # Only fills the placeholder, so after the first run, change the password # in AdGuardHome.yaml or the UI, not in PI_ADGUARD_ADMIN_HASH. - name: Set AdGuard admin password hash no_log: true become: true ansible.builtin.replace: path: "{{ app_dir }}/adguard-conf/AdGuardHome.yaml" regexp: "ADGUARD_ADMIN_PASSWORD_HASH" replace: "{{ lookup('env', 'PI_ADGUARD_ADMIN_HASH') }}" - name: Create and start docker compose services community.docker.docker_compose_v2: project_src: "{{ app_dir }}" pull: always build: always remove_orphans: true # The Pi is the network's DNS server, so a deploy that leaves AdGuard # unhealthy (bad config, failed image) should fail loudly. - name: Wait for AdGuard Home to report healthy community.docker.docker_container_info: name: adguardhome register: adguard_info until: adguard_info.exists and (adguard_info.container.State.Health.Status | default('') == 'healthy') retries: 30 delay: 2 - name: Schedule weekly Docker cleanup ansible.builtin.cron: name: Docker system prune weekday: "0" hour: "3" minute: "0" job: docker system prune -af --filter 'until=72h' >/dev/null 2>&1