diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..5eb13a2 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,20 @@ +# Ansible only discovers an ansible.cfg in the current working directory, so +# every command in this repository is run from ansible/. See "Getting started" +# in the README, and ANSIBLE_CONFIG for the CI case. +[defaults] +inventory = inventory/hosts.yml +roles_path = roles +retry_files_enabled = False +stdout_callback = default +callback_result_format = yaml +display_skipped_hosts = False +interpreter_python = auto_silent + +# CI runners are ephemeral, so a known_hosts file never survives to be checked +# against. Turn this back on if you deploy from a durable control node or put a +# bastion in front of the hosts — it is the one setting here worth revisiting. +host_key_checking = False + +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=30 diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000..4533fdb --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,20 @@ +--- +ansible_user: deploy +# Override per host in inventory/host_vars/.yml if sshd moves off 22. +ansible_port: 22 + +# Path to the deploy private key, produced by scripts/load-ssh-key.sh. The key +# itself is never stored in this repository — see docs/secrets.md. +ansible_ssh_private_key_file: "{{ lookup('ansible.builtin.env', 'SSH_KEY_PATH') }}" + +# Where stacks live on the hosts. A stack deploys to {{ stack_root }}/ +# unless its playbook overrides stack_dest. +stack_root: /srv/stacks + +# Image pull policy, overridden per run with `-e pull=always` to update a stack. +# See "Deploy versus update" in the README. +pull: policy + +# Repository root, so playbooks and the role can find payloads under server/ +# regardless of the directory ansible-playbook was invoked from. +repo_root: "{{ (inventory_dir ~ '/../..') | realpath }}" diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml new file mode 100644 index 0000000..a81f02c --- /dev/null +++ b/ansible/inventory/hosts.yml @@ -0,0 +1,14 @@ +--- +# Two example hosts. Connection settings live in group_vars/all.yml, and key +# material lives nowhere in this repository — see docs/secrets.md. +# +# The names here must match the directory names under server/: the compose_stack +# role resolves a payload as server/{{ inventory_hostname }}/{{ stack_name }}. +all: + hosts: + # Public entry point: the reverse proxy and its certificates. + edge: + ansible_host: edge.example.com + # Everything behind it. + app: + ansible_host: app.example.com diff --git a/ansible/playbooks/banner.yml b/ansible/playbooks/banner.yml new file mode 100644 index 0000000..4bb8988 --- /dev/null +++ b/ansible/playbooks/banner.yml @@ -0,0 +1,50 @@ +--- +# Not a Compose stack, and deliberately not forced into the role. +# +# banner.html is a single shared file that belongs to the *reverse-proxy* stack's +# directory. There is no payload directory of its own and no compose file to +# bring up, and compose_stack syncs a whole directory and then calls +# `docker compose up`, so it cannot express this. Plain tasks instead. +# +# This is the escape hatch, and it is a small one. If you find yourself writing a +# third playbook like this, that is a sign the role's contract should grow +# instead — see AGENTS.md. +- name: Deploy the shared default-vhost banner + hosts: + - edge + - app + gather_facts: false + tags: [banner] + + vars: + # One file, shared by both hosts, unlike the per-host stack payloads. + banner_src: "{{ repo_root }}/server/shared/components/banner.html" + banner_html_dir: "{{ stack_root }}/reverse-proxy/proxy-data/html" + + tasks: + - name: Ensure the reverse proxy's html directory exists + # reverse-proxy.yml creates this too. Repeated here so this playbook runs + # standalone on a host where the proxy payload has never been synced — + # every playbook in this repository stands on its own (AGENTS.md, rule 2). + ansible.builtin.file: + path: "{{ banner_html_dir }}" + state: directory + mode: "0755" + + - name: Install the shared banner + ansible.builtin.copy: + src: "{{ banner_src }}" + dest: "{{ banner_html_dir }}/index.html" + mode: "0644" + notify: Reload nginx + + handlers: + # html/ is a bind mount, so the container is never recreated for this file; + # the reload is the only thing that applies it. It fires only when the copy + # above reported a change, so a repeat run is a genuine no-op. It fails if + # the reverse proxy is not running, which means reverse-proxy.yml has never + # run on this host. + - name: Reload nginx + ansible.builtin.command: + cmd: docker exec reverse-proxy nginx -s reload + changed_when: true diff --git a/ansible/playbooks/metrics.yml b/ansible/playbooks/metrics.yml new file mode 100644 index 0000000..c8a80a9 --- /dev/null +++ b/ansible/playbooks/metrics.yml @@ -0,0 +1,68 @@ +--- +# One stack, both hosts, one payload. +# +# Demonstrates three things: +# - stack_src pointing outside the per-host convention, so two hosts share a +# single payload instead of keeping duplicate copies in step by hand; +# - a per-host variable map, which is how a play covers hosts that differ +# without growing a second playbook or a `when:` on every task; +# - stack_dirs entries that set ownership, and ones that only set a mode. +- name: Deploy metrics + hosts: + - edge + - app + gather_facts: false + tags: [metrics] + + vars: + # Only the app host runs anything worth proxying a dashboard to, so only it + # joins the private backend network. Keyed by inventory_hostname and looked + # up below — adding a host means adding a line here, not a branch. + metrics_networks: + edge: + - proxy-net + app: + - proxy-net + - backend + + metrics_dirs: + # The Grafana image runs as uid 472 and this is a bind mount, so the + # directory has to be owned by that uid on the host. `recurse: true` with + # no `mode`: recursing a mode here would rewrite the permissions of + # Grafana's SQLite database, which holds hashed credentials and datasource + # secrets. + # + # This belongs in stack_dirs rather than a pre_tasks chown. pre_tasks run + # before the role creates the stack directory, and an escalated + # ansible.builtin.file on a child stamps its owner onto every parent it + # creates on the way down — which would leave the payload sync, running as + # the deploy user, unable to write into the stack's own directory on a host + # where metrics has never been deployed. + - path: "{{ stack_root }}/metrics/grafana-data" + owner: "472" + group: "472" + recurse: true + # Written by the prometheus container, which runs as nobody (65534). A mode + # rather than an owner because the uid is not stable across image versions. + # Narrow this to an owner once you have confirmed the uid on your hosts. + - path: "{{ stack_root }}/metrics/prometheus-data" + mode: "0777" + + roles: + - role: compose_stack + vars: + stack_name: metrics + # Both hosts run the same thing, so the payload lives under server/shared/ + # rather than being copied into server/edge/ and server/app/. + stack_src: "{{ repo_root }}/server/shared/metrics" + stack_networks: "{{ metrics_networks[inventory_hostname] }}" + stack_dirs: "{{ metrics_dirs }}" + + post_tasks: + - name: Reload prometheus so a changed scrape configuration takes effect + # prometheus.yml is bind-mounted, so Compose never recreates the container + # for a change to it. Prometheus re-reads its configuration on SIGHUP. + ansible.builtin.command: + cmd: docker kill --signal=SIGHUP prometheus + when: compose_stack_synced | default(false) + changed_when: true diff --git a/ansible/playbooks/reverse-proxy.yml b/ansible/playbooks/reverse-proxy.yml new file mode 100644 index 0000000..ded6608 --- /dev/null +++ b/ansible/playbooks/reverse-proxy.yml @@ -0,0 +1,45 @@ +--- +# The public entry point, and the stack every other stack depends on: it owns +# proxy-net and the generated vhost configuration, so site.yml runs it first. +# +# Demonstrates the two things a payload made of plain configuration files needs: +# bind-mount directories created before the container starts, and a reload after +# the payload changes. +- name: Deploy the reverse proxy + hosts: edge + gather_facts: false + tags: [reverse-proxy] + + roles: + - role: compose_stack + vars: + stack_name: reverse-proxy + stack_networks: + - proxy-net + stack_dirs: + # Bind mounts the compose file declares but no payload provides. Docker + # would otherwise create them root-owned on first start, and html/ in + # particular has to be writable by the deploy user before banner.yml + # can put a file in it. + # + # No `mode` on any of them on purpose: the role only applies the + # attributes an entry actually sets, so a directory that already exists + # on the host keeps whatever permissions it has. + - path: "{{ stack_root }}/reverse-proxy/proxy-data/conf" + - path: "{{ stack_root }}/reverse-proxy/proxy-data/vhost" + - path: "{{ stack_root }}/reverse-proxy/proxy-data/html" + - path: "{{ stack_root }}/reverse-proxy/proxy-data/dhparam" + # certs/ and acme/ are deliberately absent. They hold live private keys + # and are managed by acme-companion; nothing here should touch their + # ownership or permissions. + + post_tasks: + - name: Reload nginx so changed vhost and conf files take effect + # conf/ and vhost/ are bind mounts, so editing a file in them never causes + # Compose to recreate the container — this reload is the only thing that + # applies the change. compose_stack_synced is set by the role from the + # payload sync, so a run that changed nothing stays a genuine no-op. + ansible.builtin.command: + cmd: docker exec reverse-proxy nginx -s reload + when: compose_stack_synced | default(false) + changed_when: true diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000..b790e59 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,25 @@ +--- +# Every stack, in dependency order. Each imported playbook also runs on its own +# — see AGENTS.md, rule 2. +# +# ansible-playbook playbooks/site.yml # converge everything +# ansible-playbook playbooks/site.yml --tags metrics # one stack +# ansible-playbook playbooks/site.yml -e pull=always # update everything +# +# Order matters. The reverse proxy owns proxy-net and the vhost configuration +# that every other stack registers against, so it goes first; banner.yml writes +# into the proxy's html directory, so it goes second. + +- name: Reverse proxy + ansible.builtin.import_playbook: reverse-proxy.yml +- name: Default vhost banner + ansible.builtin.import_playbook: banner.yml +- name: Metrics + ansible.builtin.import_playbook: metrics.yml + +- name: Static site + ansible.builtin.import_playbook: static-site.yml +- name: Webapp + ansible.builtin.import_playbook: webapp.yml +- name: Webapp staging + ansible.builtin.import_playbook: webapp-staging.yml diff --git a/ansible/playbooks/static-site.yml b/ansible/playbooks/static-site.yml new file mode 100644 index 0000000..65812ca --- /dev/null +++ b/ansible/playbooks/static-site.yml @@ -0,0 +1,16 @@ +--- +# The reference playbook. Most stacks look exactly like this: a name, the +# networks the compose file declares external, and nothing else. Copy this file +# when you add a service, and only reach for the rest of the contract when the +# stack genuinely needs it. +- name: Deploy the static site + hosts: app + gather_facts: false + tags: [static-site] + + roles: + - role: compose_stack + vars: + stack_name: static-site + stack_networks: + - proxy-net diff --git a/ansible/playbooks/webapp-staging.yml b/ansible/playbooks/webapp-staging.yml new file mode 100644 index 0000000..0551a54 --- /dev/null +++ b/ansible/playbooks/webapp-staging.yml @@ -0,0 +1,35 @@ +--- +# A second instance of webapp, from the same payload. +# +# Demonstrates stack_src, stack_dest and stack_env together: one set of compose +# files, deployed twice to the same host under different directories with +# different settings. This is much better than a forked copy of the payload, +# which drifts the first time someone fixes a bug in only one of them. +# +# Staging deliberately has no backup-and-migrate phase. It is rebuilt from the +# same images and its data is disposable, so the whole two-phase apparatus in +# webapp.yml would be ceremony here. +- name: Deploy webapp staging + hosts: app + gather_facts: false + tags: [webapp-staging] + + roles: + - role: compose_stack + vars: + stack_name: webapp-staging + # The same payload as the production instance. + stack_src: "{{ repo_root }}/server/app/webapp" + # ...deployed somewhere else. Without this, stack_dest would default to + # {{ stack_root }}/webapp-staging, which happens to be right here, but + # spelling it out is what makes the pairing with stack_src obvious. + stack_dest: "{{ stack_root }}/webapp-staging" + stack_networks: + - proxy-net + stack_files: + - docker-compose.yml + # Per-instance settings, merged into the compose environment on top of + # PWD. The compose file reads these; nothing in the payload knows which + # instance it is. + stack_env: + COMPOSE_PROJECT_NAME: webapp-staging diff --git a/ansible/playbooks/webapp.yml b/ansible/playbooks/webapp.yml new file mode 100644 index 0000000..b677d36 --- /dev/null +++ b/ansible/playbooks/webapp.yml @@ -0,0 +1,111 @@ +--- +# 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 diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..0094df1 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,8 @@ +--- +collections: + # docker_compose_v2 — bringing stacks up. + - name: community.docker + version: ">=3.10.0" + # synchronize — rsyncing stack payloads to the hosts. + - name: ansible.posix + version: ">=1.5.0" diff --git a/ansible/roles/compose_stack/defaults/main.yml b/ansible/roles/compose_stack/defaults/main.yml new file mode 100644 index 0000000..ef9e845 --- /dev/null +++ b/ansible/roles/compose_stack/defaults/main.yml @@ -0,0 +1,53 @@ +--- +# The variable contract. Playbooks set these; nothing else should. Every entry is +# documented in AGENTS.md — keep the two in step. + +# Required. Names the stack, and by default its payload and destination too. +stack_name: "" + +# Payload directory in this repository, and where it lands on the host. Override +# stack_src to share one payload between hosts or between two instances of the +# same application; override stack_dest when the remote directory cannot be +# named after the stack. +stack_src: "{{ repo_root }}/server/{{ inventory_hostname }}/{{ stack_name }}" +stack_dest: "{{ stack_root | default('/srv/stacks') }}/{{ stack_name }}" + +# Extra directories to create before the stack starts. Each entry needs a `path`; +# `mode`, `owner`, `group` and `recurse` are optional and applied only when +# given, so a directory that already exists on the host is never re-chmodded or +# re-chowned. +stack_dirs: [] + +# Extra environment for the compose invocation. PWD is always set to stack_dest; +# anything here is merged on top. +stack_env: {} + +# External Docker networks the compose file expects to already exist. +stack_networks: [] + +# Compose files, relative to stack_dest, in the order `docker compose -f` wants. +stack_files: + - docker-compose.yml + +# Never ship secrets to the host through the payload. Extend this list; do not +# replace it without understanding what you are letting through. +stack_exclude: + - .env + +# Whether the sync removes host files that are no longer in the payload. Off by +# default: a stack's directory usually also holds data the payload knows nothing +# about (bind mounts, certificates, logs). +stack_prune: false + +# present, absent, or restarted. +stack_state: present + +# Image pull policy for this invocation. Defaults to the repo-wide `pull` flag so +# `-e pull=always` keeps working, but because it is a role variable a playbook +# can override it for a single call — for example to sync with the old images, +# run a database upgrade, and only then bring the stack up on the new ones. +stack_pull: "{{ pull | default('policy') }}" + +# Compose build policy. `policy` builds only when the image is missing; stacks +# that build from a Dockerfile in their payload want `always` on an update run. +stack_build: policy diff --git a/ansible/roles/compose_stack/meta/main.yml b/ansible/roles/compose_stack/meta/main.yml new file mode 100644 index 0000000..9da1676 --- /dev/null +++ b/ansible/roles/compose_stack/meta/main.yml @@ -0,0 +1,25 @@ +--- +galaxy_info: + role_name: compose_stack + namespace: example + author: The contributors + description: Sync a Docker Compose stack payload to a host and bring the stack up. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: EL + versions: + - "9" + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + galaxy_tags: + - docker + - compose + - deployment + +dependencies: [] diff --git a/ansible/roles/compose_stack/tasks/main.yml b/ansible/roles/compose_stack/tasks/main.yml new file mode 100644 index 0000000..3fcd3c5 --- /dev/null +++ b/ansible/roles/compose_stack/tasks/main.yml @@ -0,0 +1,20 @@ +--- +# Two entry points, so a playbook that must do maintenance between the payload +# landing and the stack starting can interleave its own tasks: +# +# - ansible.builtin.include_role: +# name: compose_stack +# tasks_from: sync +# - name: Run the database migration +# ... +# - ansible.builtin.include_role: +# name: compose_stack +# tasks_from: up +# +# Using the role normally (roles: - role: compose_stack) runs both, in order. +# playbooks/webapp.yml is the worked example. +- name: Sync the payload to the host + ansible.builtin.include_tasks: sync.yml + +- name: Start the stack + ansible.builtin.include_tasks: up.yml diff --git a/ansible/roles/compose_stack/tasks/sync.yml b/ansible/roles/compose_stack/tasks/sync.yml new file mode 100644 index 0000000..aa3fd72 --- /dev/null +++ b/ansible/roles/compose_stack/tasks/sync.yml @@ -0,0 +1,82 @@ +--- +- name: Verify the caller named a stack + ansible.builtin.assert: + that: + - stack_name | length > 0 + fail_msg: compose_stack requires stack_name + quiet: true + +- name: Look for the payload on the control node + ansible.builtin.stat: + path: "{{ stack_src }}" + register: compose_stack_payload + delegate_to: localhost + become: false + changed_when: false + +# A missing payload otherwise surfaces as an empty sync that reports success and +# leaves the host running whatever was there before. +- name: Verify the payload directory exists + ansible.builtin.assert: + that: + - compose_stack_payload.stat.isdir | default(false) + fail_msg: "no payload directory at {{ stack_src }}" + quiet: true + +- name: Create the stack directory + ansible.builtin.file: + path: "{{ stack_dest }}" + state: directory + mode: "0755" + +- name: Create the extra directories the stack needs + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + # All four default to omit: create the directory if it is absent, and leave + # whatever is already on the host alone. Bind mounts are routinely created + # root-owned by Docker on first start, and re-chmodding those breaks them. + mode: "{{ item.mode | default(omit) }}" + owner: "{{ item.owner | default(omit) }}" + group: "{{ item.group | default(omit) }}" + # For a bind mount whose existing tree is owned by the wrong uid. Set it with + # `owner`/`group` and no `mode`: recursing a mode over live data rewrites the + # permissions of everything the container has already written there. + recurse: "{{ item.recurse | default(omit) }}" + # Escalate only to set an owner or group. Creating these as root would defeat + # the point of pre-creating them at all — callers do it precisely so the bind + # mount is *not* root-owned when Docker first starts the stack. Unescalated + # they land as the deploy user, which is the user Compose runs as. + become: "{{ item.owner is defined or item.group is defined }}" + loop: "{{ stack_dirs }}" + loop_control: + label: "{{ item.path }}" + +# Deliberately the docker CLI rather than community.docker.docker_network: that +# module needs the Docker SDK for Python installed on every host, and creating a +# network is the only thing this repository would need it for. +- name: Create the external Docker networks + ansible.builtin.command: + cmd: docker network create {{ item }} + register: compose_stack_network + changed_when: compose_stack_network.rc == 0 + failed_when: + - compose_stack_network.rc != 0 + - '"already exists" not in compose_stack_network.stderr' + loop: "{{ stack_networks }}" + +- name: Sync the payload to the host + ansible.posix.synchronize: + src: "{{ stack_src }}/" + dest: "{{ stack_dest }}/" + delete: "{{ stack_prune }}" + rsync_opts: "{{ stack_exclude | map('regex_replace', '^', '--exclude=') | list }}" + register: compose_stack_sync + +# Bind-mounted configuration changes without the compose file changing, so +# docker_compose_v2 will not recreate the container and the new file has no +# effect. Playbooks key a reload task off this fact — see +# playbooks/reverse-proxy.yml. +- name: Record whether the payload changed + ansible.builtin.set_fact: + compose_stack_synced: "{{ compose_stack_sync.changed }}" diff --git a/ansible/roles/compose_stack/tasks/up.yml b/ansible/roles/compose_stack/tasks/up.yml new file mode 100644 index 0000000..f9e938b --- /dev/null +++ b/ansible/roles/compose_stack/tasks/up.yml @@ -0,0 +1,14 @@ +--- +- name: Bring the stack up + community.docker.docker_compose_v2: + project_src: "{{ stack_dest }}" + files: "{{ stack_files }}" + state: "{{ stack_state }}" + pull: "{{ stack_pull }}" + build: "{{ stack_build }}" + remove_orphans: true + # A compose file that interpolates ${PWD} — a common way to hand a host path to + # a sibling container — resolves it against the *invoking shell*, not the + # project directory. The module sets the process working directory but not PWD, + # so ${PWD} would silently become the SSH login directory. Set it explicitly. + environment: "{{ {'PWD': stack_dest} | combine(stack_env) }}"