ansible/deploy.yml
Rama main · réplica de lectura del repositorio original.
---
# 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