--- # The stack that needs more than "sync, then up". # # Demonstrates the whole heavy end of the contract: an assertion that a host-side # .env exists, a payload built from a Dockerfile, a second compose file holding # maintenance-only services, and work that has to happen *between* the payload # landing and the stack starting. # # The shape to copy is the two entry points. `roles: - role: compose_stack` runs # sync and then up, which is what almost every stack wants. When something has to # happen in between, include the role twice with tasks_from — do not invent a # second mechanism, and do not write a separate *-update.yml playbook. - name: Deploy webapp hosts: app gather_facts: false tags: [webapp] vars: webapp_dir: "{{ stack_root }}/webapp" # Both compose files, in the order `docker compose -f` wants. Only the # maintenance runs below use this; see the note on stack_files. webapp_maintenance_compose: >- -f docker-compose.yml -f docker-compose.migrate.yml # The single flag that separates a deploy from an update. Read it through # `default` at every use site so the playbook still runs when `pull` is # undefined. webapp_updating: "{{ (pull | default('policy')) == 'always' }}" # The compose_stack contract, declared at play level so that both the sync # phase and the up phase below see the same values. Inside `roles:` they # would be scoped to the role invocation and the second include would not # see them. stack_name: webapp stack_dest: "{{ webapp_dir }}" stack_networks: - proxy-net # Deliberately only docker-compose.yml. docker-compose.migrate.yml adds # one-shot `run --rm` targets; bringing the stack up with both files would # start them as long-running services. stack_files: - docker-compose.yml # Rebuild the image only on an update run. Rebuilding on every deploy would # report a change every time and quietly break idempotency. stack_build: "{{ 'always' if webapp_updating else 'policy' }}" stack_dirs: # Bind mounts Docker would otherwise create root-owned. ./pgdata is # deliberately absent: Postgres refuses to start unless its data directory # is 0700 or 0750, so it must be left to the image. - path: "{{ stack_root }}/webapp/uploads" - path: "{{ stack_root }}/webapp/backups" pre_tasks: # Every variable in this stack's compose files comes from .env with no # defaults, and the role never ships .env to a host. Fail loudly and say what # to do, rather than starting a stack with empty credentials. - name: Look for the webapp .env on the host ansible.builtin.stat: path: "{{ webapp_dir }}/.env" register: webapp_env changed_when: false - name: Verify the webapp .env is present on the host ansible.builtin.assert: that: - webapp_env.stat.exists fail_msg: >- {{ webapp_dir }}/.env is missing on {{ inventory_hostname }}. webapp reads every credential from it and it is never synced from this repository; copy .env.example on the host and fill it in, or restore it from your secret store. quiet: true tasks: # Phase 1: deliver the payload, including the current Dockerfile and # migration script, without starting anything. - name: Sync the webapp payload ansible.builtin.include_role: name: compose_stack tasks_from: sync # Phase 2: maintenance, against the payload that was just delivered and only # on an update run. An ordinary deploy must never back up or migrate # anything — see "Deploy versus update" in the README. - name: Build the webapp maintenance images ansible.builtin.command: cmd: docker compose {{ webapp_maintenance_compose }} build chdir: "{{ webapp_dir }}" changed_when: true when: webapp_updating - name: Back up the webapp database before migrating ansible.builtin.command: cmd: docker compose {{ webapp_maintenance_compose }} run --rm backup chdir: "{{ webapp_dir }}" changed_when: true when: webapp_updating - name: Migrate the webapp database # app/migrate.sh is idempotent by design, so repeating an update run after # a failure elsewhere is safe. ansible.builtin.command: cmd: docker compose {{ webapp_maintenance_compose }} run --rm migrate chdir: "{{ webapp_dir }}" changed_when: true when: webapp_updating # Phase 3: start the stack, on the new images if any were pulled or built. - name: Start webapp ansible.builtin.include_role: name: compose_stack tasks_from: up