--- # update_valheim.yml # Requires Docker Compose >= 2.18 and Python 3 on pve2_vm. # Uses ansible.builtin only, like the command-based Minecraft update workflow. # A run ALWAYS restarts Valheim, even if the image did not change. # Do not run concurrently with another update or a Portainer redeployment. - name: Deploy or update Valheim hosts: pve2_vm gather_facts: false become: true vars: compose_local_dir: "{{ playbook_dir }}/docker-compose" compose_remote_base: "/home/{{ ansible_user }}/.ansible-compose" # NOT the shared /docker-compose directory removed by other playbooks. compose_remote_dir: "{{ compose_remote_base }}/valheim" valheim_compose_filename: "docker-compose-valheim.yml" valheim_project: valheim valheim_service: valheim valheim_wait_timeout: 1800 valheim_preupdate_dir: "/backups/valheim/pre-update" valheim_preupdate_keep_days: 14 # Read paths/UID/GID from the controller's compose file to avoid duplication. valheim_compose: >- {{ lookup('ansible.builtin.file', compose_local_dir ~ '/' ~ valheim_compose_filename) | from_yaml }} valheim_spec: "{{ valheim_compose.services[valheim_service] }}" valheim_container: "{{ valheim_spec.container_name }}" valheim_uid: "{{ valheim_spec.environment.PUID }}" valheim_gid: "{{ valheim_spec.environment.PGID }}" valheim_password_file: >- {{ (valheim_spec.volumes | selectattr('target', 'equalto', '/run/secrets/valheim_server_pass') | first).source }} valheim_config_dir: >- {{ (valheim_spec.volumes | selectattr('target', 'equalto', '/config') | first).source }} valheim_compose_argv: - docker - compose - --project-name - "{{ valheim_project }}" - --project-directory - "{{ compose_remote_dir }}" - --file - "{{ compose_remote_dir }}/{{ valheim_compose_filename }}" pre_tasks: - name: Reject check mode before making changes ansible.builtin.assert: that: - not ansible_check_mode fail_msg: "This maintenance playbook does not support --check. Use --syntax-check instead." - name: Check Docker Compose version ansible.builtin.command: argv: [docker, compose, version, --short] register: valheim_compose_version changed_when: false - name: Require Compose with wait support ansible.builtin.assert: that: - >- (valheim_compose_version.stdout | regex_search('[0-9]+\.[0-9]+\.[0-9]+')) is version('2.18.0', '>=') fail_msg: "Install/update the Docker Compose plugin on pve2_vm (minimum 2.18.0)." - name: Inspect the persistent password file without reading its contents ansible.builtin.stat: path: "{{ valheim_password_file }}" get_checksum: false get_mime: false register: valheim_password_stat - name: Require a real password file on the VM ansible.builtin.assert: that: - valheim_password_stat.stat.isreg | default(false) - not (valheim_password_stat.stat.islnk | default(false)) fail_msg: >- Create {{ valheim_password_file }} on pve2_vm first. It must contain ONLY the server password on one line, at least 5 characters, not SERVER_PASS=... . See README-valheim.md. The playbook never generates or overwrites it. - name: Validate the password without logging it ansible.builtin.command: argv: - python3 - -c - | from pathlib import Path import sys try: password = Path(sys.argv[1]).read_text(encoding="utf-8").rstrip("\r\n") except (OSError, UnicodeError): raise SystemExit("Cannot read the password file as UTF-8.") if (len(password) < 5 or "\n" in password or "\r" in password or "\x00" in password or password.startswith("SERVER_PASS=")): raise SystemExit("Use a single plain password of at least 5 characters.") - "{{ valheim_password_file }}" changed_when: false no_log: true - name: Restrict password ownership and permissions to the configured game UID ansible.builtin.file: path: "{{ valheim_password_file }}" owner: "{{ valheim_uid }}" group: "{{ valheim_gid }}" mode: "0600" - name: Inspect an existing Valheim container ansible.builtin.command: argv: [docker, container, inspect, "{{ valheim_container }}"] register: valheim_inspect changed_when: false failed_when: >- valheim_inspect.rc != 0 and 'No such' not in valheim_inspect.stderr no_log: true - name: Save only the existing container metadata in Ansible memory ansible.builtin.set_fact: valheim_existing: "{{ (valheim_inspect.stdout | from_json | first) if valheim_inspect.rc == 0 else {} }}" no_log: true - name: Refuse to take over a container from a different project or service ansible.builtin.assert: that: - >- (valheim_existing.get('Config', {}).get('Labels') or {}).get('com.docker.compose.project') == valheim_project - >- (valheim_existing.get('Config', {}).get('Labels') or {}).get('com.docker.compose.service') == valheim_service fail_msg: "Container name is occupied. The existing container must belong to stack/service valheim." when: valheim_existing | length > 0 - name: Refuse a silent switch to different persistent data or password paths ansible.builtin.assert: that: - >- (valheim_existing.Mounts | selectattr('Destination', 'equalto', item.target) | map(attribute='Source') | list) == [item.source] fail_msg: >- Existing mount {{ item.target }} does not match {{ item.source }}. Migrate the data deliberately before changing the compose bind paths. loop: "{{ valheim_spec.volumes }}" loop_control: label: "{{ item.target }}" when: valheim_existing | length > 0 tasks: - name: Ensure the dedicated remote compose directory exists ansible.builtin.file: path: "{{ compose_remote_dir }}" state: directory owner: "{{ ansible_user }}" mode: "0755" - name: Ensure the writable host bind directories exist ansible.builtin.file: path: "{{ item.source }}" state: directory owner: "{{ valheim_uid }}" group: "{{ valheim_gid }}" mode: "0750" loop: "{{ valheim_spec.volumes }}" when: not (item.read_only | default(false) | bool) loop_control: label: "{{ item.source }}" - name: Ensure the offline pre-update backup directory exists ansible.builtin.file: path: "{{ valheim_preupdate_dir }}" state: directory mode: "0755" - name: Upload only the Valheim compose file ansible.builtin.copy: src: "{{ compose_local_dir }}/{{ valheim_compose_filename }}" dest: "{{ compose_remote_dir }}/{{ valheim_compose_filename }}" owner: "{{ ansible_user }}" mode: "0644" - name: Validate the compose configuration before interrupting the game ansible.builtin.command: argv: "{{ valheim_compose_argv + ['config', '--quiet'] }}" changed_when: false - name: Pull the image while the old server is still running ansible.builtin.command: argv: "{{ valheim_compose_argv + ['pull', valheim_service] }}" # Pull output is not a stable machine-readable change indicator. changed_when: false - name: Stop and make a consistent offline config/world backup block: - name: Gracefully stop only the existing Valheim container ansible.builtin.command: argv: [docker, stop, --time, "120", "{{ valheim_container }}"] when: valheim_existing.get('State', {}).get('Running', false) changed_when: true - name: Allocate a unique pre-update archive filename ansible.builtin.tempfile: state: file path: "{{ valheim_preupdate_dir }}" prefix: "config-{{ now(utc=true, fmt='%Y%m%dT%H%M%SZ') }}-" suffix: ".tar.gz" register: valheim_backup - name: Back up config and worlds, excluding the separate automatic backup mount ansible.builtin.command: argv: - tar - --create - --gzip - --file - "{{ valheim_backup.path }}" - --exclude=./backups - --directory - "{{ valheim_config_dir }}" - . changed_when: true rescue: - name: Abort rather than update without a completed backup ansible.builtin.fail: msg: >- Stop/backup failed; the replacement container was not deployed. The original container may now be stopped. It is NOT automatically restarted, because starting it can itself trigger a Steam game update without a valid backup. Check docker ps and disk space on pve2_vm; an incomplete archive may remain. Fix the backup failure and run this playbook again. - name: Recreate Valheim and verify local readiness and the startup Steam check block: - name: Recreate only Valheim and wait for its healthcheck ansible.builtin.command: argv: >- {{ valheim_compose_argv + ['up', '--detach', '--force-recreate', '--no-deps', '--pull', 'never', '--wait', '--wait-timeout', valheim_wait_timeout | string, valheim_service] }} changed_when: true - name: Read new-container startup logs without exposing them in Semaphore ansible.builtin.command: argv: [docker, logs, "{{ valheim_container }}"] register: valheim_start_logs changed_when: false no_log: true - name: Confirm Steam update completed rather than falling back to old binaries ansible.builtin.assert: that: - >- 'Valheim Server was updated - restarting' in (valheim_start_logs.stdout ~ valheim_start_logs.stderr) or 'Valheim Server is already the latest version' in (valheim_start_logs.stdout ~ valheim_start_logs.stderr) fail_msg: >- The container is healthy, but no successful Steam update marker was found. Inspect docker logs valheim on the VM: Steam may have failed and the image may be using its previous game installation, or upstream log wording changed. rescue: - name: Report failure without destructive rollback or dumping potentially sensitive logs ansible.builtin.fail: msg: >- Valheim readiness or Steam-update verification failed. Inspect 'docker ps -a --filter name=valheim' and 'docker logs --tail 150 valheim' on pve2_vm. The pre-update config/world archive is {{ valheim_backup.path }}. No data directories were deleted and no automatic downgrade/restore was attempted. - name: Find expired offline archives only after a successful deployment ansible.builtin.find: paths: "{{ valheim_preupdate_dir }}" patterns: "config-*.tar.gz" age: "{{ valheim_preupdate_keep_days }}d" age_stamp: mtime file_type: file recurse: false register: valheim_old_backups - name: Remove only expired Valheim pre-update archives ansible.builtin.file: path: "{{ item.path }}" state: absent loop: "{{ valheim_old_backups.files }}" loop_control: label: "{{ item.path }}" - name: Report completion ansible.builtin.debug: msg: - "Valheim passed local health and startup Steam-update checks." - "Offline config/world backup: {{ valheim_backup.path }}" - "Join using the VM address and UDP port 2456. Router/firewall reachability is not tested."