--- # myNextScreen — Full Deployment Playbook (GHCR pull model) # Usage: ansible-playbook -i hosts.ini deploy.yml -K # # Images are built + published to GHCR by CI (.github/workflows/ci.yml). # This playbook pulls the published images and runs them via rootless Podman # Quadlets. Build nothing locally. # # Required variables (set in hosts.ini, group_vars, or -e): # domain_name: Base domain (e.g., mynextscreen.example.com) # jwt_access_secret: Strong random secret for signing access JWTs (vault it) # secrets_encryption_key: 32-byte base64 key that encrypts every stored secret # (openssl rand -base64 32; vault it, back it up apart from DB dumps) # service_user: Linux user that runs Podman (default: mynextscreen) # mynextscreen_image_tag: GHCR tag to deploy (default: the version in package.json) # ghcr_username: GHCR user for `podman login` (default: image owner) # ghcr_token: GHCR PAT (read:packages). Omit if packages are public. # vault_mynextscreen_postgres_password: Postgres password (vault it; no default) - name: Deploy to server hosts: servers become: true vars: service_user: mynextscreen service_user_home: "/home/{{ service_user }}" quadlet_dir: "{{ service_user_home }}/.config/containers/systemd" image_registry: "ghcr.io/flo-schilli/mynextscreen" # Pinned to the version of the checkout being deployed, not `latest`: # combined with AutoUpdate=registry, a moving tag meant production drifted # to whatever CI pushed last, with no reproducible rollback target. # Override with -e mynextscreen_image_tag=1.2.3 to deploy a different release. mynextscreen_image_tag: "{{ (lookup('file', playbook_dir + '/../package.json') | from_json).version }}" ghcr_username: flo-schilli # Fail-closed: must be provided via Ansible Vault or --extra-vars. mynextscreen_postgres_password: "{{ vault_mynextscreen_postgres_password | mandatory }}" app_dirs: - "{{ service_user_home }}/app" - "{{ service_user_home }}/app/data" - "{{ service_user_home }}/app/media" - "{{ service_user_home }}/app/backups" app_images: - backend - frontend - player services: - mynextscreen-redis - mynextscreen-postgres - mynextscreen-backend - mynextscreen-frontend - mynextscreen-player pre_tasks: # Secrets are never stored in plaintext: the backend refuses to boot without # this key, so fail here — before anything on the host changes. - name: Require SECRETS_ENCRYPTION_KEY (32 bytes, base64) ansible.builtin.assert: that: - secrets_encryption_key is defined - secrets_encryption_key is match('^[A-Za-z0-9+/]{43}=$') fail_msg: >- secrets_encryption_key is missing or not 32 bytes base64. Generate it with `openssl rand -base64 32`, store it in Ansible Vault and back it up separately from database dumps. Existing plaintext secrets are encrypted automatically on the next backend start. quiet: true no_log: true tasks: # ─── System packages ────────────────────────────────────── - name: Install required packages ansible.builtin.dnf: name: - podman - firewalld - caddy - fail2ban - sqlite - acl - net-tools state: present - name: Ensure firewalld is running ansible.builtin.systemd: name: firewalld state: started enabled: true - name: Open firewall ports for HTTP/HTTPS ansible.posix.firewalld: service: "{{ item }}" permanent: true immediate: true state: enabled loop: - http - https # ─── Service user ───────────────────────────────────────── - name: Create service user ansible.builtin.user: name: "{{ service_user }}" shell: /bin/bash create_home: true state: present - name: Enable linger for service user ansible.builtin.command: cmd: loginctl enable-linger {{ service_user }} changed_when: true # ─── Directory structure ────────────────────────────────── - name: Create application directories ansible.builtin.file: path: "{{ item }}" state: directory owner: "{{ service_user }}" group: "{{ service_user }}" # Backups hold the full database — password hashes, API keys and the # per-org SMTP credentials — so they are owner-only, unlike the rest. mode: "{{ '0700' if item.endswith('/backups') else '0755' }}" loop: "{{ app_dirs }}" - name: Create Quadlet directory ansible.builtin.file: path: "{{ quadlet_dir }}" state: directory owner: "{{ service_user }}" group: "{{ service_user }}" mode: "0755" - name: Create Caddy log directory ansible.builtin.file: path: /var/log/caddy state: directory owner: caddy group: caddy mode: "0755" setype: httpd_log_t # ─── SELinux for Caddy logs ─────────────────────────────── - name: Install SELinux policy tools ansible.builtin.dnf: name: - policycoreutils-python-utils state: present - name: Set SELinux file context for Caddy logs community.general.sefcontext: target: "/var/log/caddy(/.*)?" setype: httpd_log_t state: present - name: Apply SELinux file context to Caddy log directory ansible.builtin.command: cmd: restorecon -Rv /var/log/caddy/ changed_when: true # Built in a root-owned private directory rather than in /tmp: compiling a # policy module from a world-readable file inside a world-writable directory # invites a symlink race on hosts with other local accounts. - name: Create SELinux policy build directory ansible.builtin.file: path: /var/lib/mynextscreen-selinux state: directory owner: root group: root mode: "0700" - name: Copy Caddy SELinux policy source ansible.builtin.copy: src: "{{ playbook_dir }}/templates/caddy_log.te" dest: /var/lib/mynextscreen-selinux/caddy_log.te owner: root group: root mode: "0600" - name: Check if Caddy log SELinux module is already loaded ansible.builtin.command: cmd: semodule -l register: semodule_list changed_when: false - name: Compile and install Caddy SELinux policy module ansible.builtin.shell: | cd /var/lib/mynextscreen-selinux checkmodule -M -m -o caddy_log.mod caddy_log.te semodule_package -o caddy_log.pp -m caddy_log.mod semodule -i caddy_log.pp when: "'caddy_log' not in semodule_list.stdout" # ─── Database backup ────────────────────────────────────── - name: Check if PostgreSQL container is running ansible.builtin.command: podman container exists mynextscreen-postgres register: pg_running changed_when: false failed_when: false - name: Backup existing PostgreSQL database (pg_dump custom format) ansible.builtin.shell: >- podman exec mynextscreen-postgres pg_dump -U mynextscreen -d mynextscreen -Fc > "{{ service_user_home }}/app/backups/mynextscreen_{{ ansible_date_time.iso8601_basic_short }}.dump" args: executable: /bin/bash when: pg_running.rc == 0 changed_when: true - name: Restrict database dump permissions ansible.builtin.file: path: "{{ service_user_home }}/app/backups" state: directory recurse: true owner: "{{ service_user }}" group: "{{ service_user }}" mode: u=rwX,g=,o= when: pg_running.rc == 0 # ─── Quadlet files and env ──────────────────────────────── - name: Copy static Quadlet files ansible.builtin.copy: src: "{{ playbook_dir }}/quadlets/{{ item }}" dest: "{{ quadlet_dir }}/{{ item }}" owner: "{{ service_user }}" group: "{{ service_user }}" mode: "0644" loop: - mynextscreen.network - mynextscreen-redis.container - mynextscreen-postgres.container - name: Template application Quadlet files (pin GHCR image tag) ansible.builtin.template: src: "{{ playbook_dir }}/templates/{{ item }}.j2" dest: "{{ quadlet_dir }}/{{ item }}" owner: "{{ service_user }}" group: "{{ service_user }}" mode: "0644" loop: - mynextscreen-backend.container - mynextscreen-frontend.container - mynextscreen-player.container - name: Generate environment file ansible.builtin.template: src: "{{ playbook_dir }}/templates/env.mynextscreen.j2" dest: "{{ quadlet_dir }}/.env.mynextscreen" owner: "{{ service_user }}" group: "{{ service_user }}" mode: "0600" - name: Generate PostgreSQL environment file ansible.builtin.template: src: "{{ playbook_dir }}/templates/env.mynextscreen-postgres.j2" dest: "{{ quadlet_dir }}/.env.mynextscreen-postgres" owner: "{{ service_user }}" group: "{{ service_user }}" mode: "0600" # ─── Stop services before image update ──────────────────── - name: Get service user UID (for container ops) ansible.builtin.command: cmd: id -u {{ service_user }} register: uid_result_early changed_when: false - name: Stop Podman services before image update ansible.builtin.command: cmd: systemctl --user stop {{ item }} become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ uid_result_early.stdout }}/bus" loop: "{{ services | reverse | list }}" failed_when: false - name: Remove old containers ansible.builtin.command: cmd: podman rm -f {{ item }} become: true become_user: "{{ service_user }}" loop: - mynextscreen-player - mynextscreen-frontend - mynextscreen-backend failed_when: false # ─── Pull images from GHCR ──────────────────────────────── - name: Log in to GHCR ansible.builtin.command: cmd: podman login ghcr.io -u {{ ghcr_username }} --password-stdin stdin: "{{ ghcr_token }}" become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}" when: ghcr_token is defined and ghcr_token | length > 0 no_log: true - name: Pull application images from GHCR ansible.builtin.command: cmd: podman pull {{ image_registry }}/{{ item }}:{{ mynextscreen_image_tag }} become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}" loop: "{{ app_images }}" - name: Pull Redis image ansible.builtin.command: cmd: podman pull docker.io/library/redis:7-alpine become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}" - name: Pull PostgreSQL image ansible.builtin.command: cmd: podman pull docker.io/library/postgres:16-alpine become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ uid_result_early.stdout }}" # ─── Caddy configuration ───────────────────────────────── - name: Ensure Caddyfile.d directory exists ansible.builtin.file: path: /etc/caddy/Caddyfile.d state: directory owner: root group: root mode: "0755" - name: Generate Caddyfile ansible.builtin.template: src: "{{ playbook_dir }}/templates/Caddyfile.j2" dest: "/etc/caddy/Caddyfile.d/{{ caddy_site_prefix | default('02') }}_{{ domain_name }}.caddyfile" owner: root group: root mode: "0644" notify: Reload Caddy # ─── Fail2ban ───────────────────────────────────────────── - name: Deploy fail2ban filter for Caddy ansible.builtin.template: src: "{{ playbook_dir }}/templates/fail2ban-caddy-filter.conf.j2" dest: /etc/fail2ban/filter.d/caddy-auth.conf owner: root group: root mode: "0644" notify: Restart fail2ban - name: Deploy fail2ban jail for Caddy ansible.builtin.template: src: "{{ playbook_dir }}/templates/fail2ban-caddy-jail.conf.j2" dest: /etc/fail2ban/jail.d/caddy-auth.conf owner: root group: root mode: "0644" notify: Restart fail2ban # ─── Start services ─────────────────────────────────────── - name: Get service user UID ansible.builtin.command: cmd: id -u {{ service_user }} register: uid_result changed_when: false - name: Set service user UID fact ansible.builtin.set_fact: service_user_uid: "{{ uid_result.stdout }}" - name: Reload systemd user daemon ansible.builtin.command: cmd: systemctl --user daemon-reload become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ service_user_uid }}" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ service_user_uid }}/bus" - name: Restart Podman services in order ansible.builtin.command: cmd: systemctl --user restart {{ item }} become: true become_user: "{{ service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ service_user_uid }}" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ service_user_uid }}/bus" loop: "{{ services }}" - name: Enable Caddy ansible.builtin.systemd: name: caddy state: started enabled: true - name: Enable fail2ban ansible.builtin.systemd: name: fail2ban state: started enabled: true handlers: - name: Reload Caddy ansible.builtin.systemd: name: caddy state: reloaded - name: Restart fail2ban ansible.builtin.systemd: name: fail2ban state: restarted