diff --git a/README.md b/README.md
index c045368..259274c 100644
--- a/README.md
+++ b/README.md
@@ -1 +1 @@
-# Ansible Collection - snailed.ez_compose
+# Ansible Collection - snailed.ez_docker
diff --git a/galaxy.yml b/galaxy.yml
index bcc3a2d..d66fbcb 100644
--- a/galaxy.yml
+++ b/galaxy.yml
@@ -1,6 +1,6 @@
 namespace: snailed
 
-name: ez_compose
+name: ez_docker
 
 version: 1.0.0
 
@@ -15,18 +15,18 @@ license:
   - MIT
 
 tags:
+  - linux
+  - infrastructure
   - docker
-  - compose
-  - docker_compose
 
 dependencies: {}
 
-repository: http://git.snaile.de/snailed/ez_compose
+repository: http://git.snaile.de/snailed/ez_docker
 
-documentation: http://git.snaile.de/snailed/ez_compose/src/branch/main/docs
+documentation: http://git.snaile.de/snailed/ez_docker/src/branch/main/docs
 
-homepage: http://git.snaile.de/snailed/ez_compose
+homepage: http://git.snaile.de/snailed/ez_docker
 
-issues: http://git.snaile.de/snailed/ez_compose/issues
+issues: http://git.snaile.de/snailed/ez_docker/issues
 
 build_ignore: []
diff --git a/plugins/lookup/container_names.py b/plugins/lookup/container_names.py
new file mode 100644
index 0000000..f6dd439
--- /dev/null
+++ b/plugins/lookup/container_names.py
@@ -0,0 +1,73 @@
+# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
+# MIT License (see LICENSE)
+# ruff: noqa: E402,ANN001,ANN003,ANN201
+# pyright: basic
+
+from __future__ import annotations
+
+DOCUMENTATION = """
+---
+name: container_names
+version_added: 1.0.0
+short_description: Discover container names from ez_docker projects
+description:
+    - This module is needed by ez_cp to find the names of containers to copy files into.
+    - It takes a project definition and returns a dict of name/container_name key/value pairs.
+author:
+    - "Luca Bilke (@ssnailed)"
+options:
+    _terms:
+        description:
+            - "name: definition dict of project to search in"
+        type: list
+        elements: dict
+        required: true
+"""
+
+EXAMPLES = """
+- name: Discover container names in projects
+  ansible.builtin.set_fact:
+    containers: "{{ lookup('snailed.ez_docker.container_names', ez_docker_projects) }}"
+"""
+
+RETURN = """
+_list:
+    description:
+        - List of discovered services in items format
+        - [{key: name, value: container_name}, ...]
+    type: list
+"""
+
+from ansible.plugins.lookup import LookupBase
+
+
+class LookupModule(LookupBase):
+    def run(self, terms, variables=None, **kwargs):
+        self.set_options(var_options=variables, direct=kwargs)
+
+        ret = []
+        for term in terms:
+            project_name = term.key
+            project = term.value
+
+            for service_name, service in project.items():
+                if not (overwrite := service.get("overwrite")):
+                    overwrite = service.get("definition")
+
+                if overwrite and (container_name := overwrite.get("container_name")):
+                    ret.append(
+                        {
+                            "key": service_name,
+                            "value": container_name,
+                        }
+                    )
+                    continue
+
+                ret.append(
+                    {
+                        "key": service_name,
+                        "value": f"{project_name}_{service_name}",
+                    }
+                )
+
+        return ret
diff --git a/plugins/lookup/slurp.py b/plugins/lookup/slurp.py
new file mode 100644
index 0000000..c9a60ca
--- /dev/null
+++ b/plugins/lookup/slurp.py
@@ -0,0 +1,74 @@
+# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
+# MIT License (see LICENSE)
+# ruff: noqa: E402,ANN001,ANN003,ANN201
+# pyright: basic
+
+from __future__ import annotations
+
+DOCUMENTATION = """
+---
+module: slurp
+version_added: 1.0.0
+short_description: Lookup version of the slurp module
+description:
+    - Instead of running the slurp module against local host, I wrote this module
+author:
+    - "Luca Bilke (@ssnailed)"
+options:
+    _terms:
+    description: path(s) of files to read
+    required: True
+seealso:
+    - ref: playbook_task_paths
+    description: Search paths used for relative files.
+"""
+
+EXAMPLES = """
+- ansible.builtin.debug:
+    msg: "{{ lookup('snailed.ez_docker.slurp', '/etc/foo.txt') | b64decode }}"
+"""
+
+RETURN = """
+  _list:
+    description:
+      - content of file(s)
+    type: list
+    elements: str
+"""
+
+import base64
+
+from ansible.errors import AnsibleError, AnsibleLookupError, AnsibleOptionsError
+from ansible.plugins.lookup import LookupBase
+from ansible.utils.display import Display
+
+display = Display()
+
+
+class LookupModule(LookupBase):
+    def run(self, terms, variables=None, **kwargs):
+        ret = []
+        self.set_options(var_options=variables, direct=kwargs)
+
+        for term in terms:
+            display.debug(f"File lookup term: {term}")
+
+            try:
+                lookupfile = self.find_file_in_search_path(
+                    variables, "files", term, ignore_missing=True,
+                )
+                display.vvvv(f"File lookup using {lookupfile} as file")
+
+                if lookupfile:
+                    b_contents, _ = self._loader._get_file_contents(lookupfile)  # noqa: SLF001 # pyright: ignore[reportOptionalMemberAccess]
+                    contents = base64.b64encode(b_contents)
+                    ret.append(contents)
+                else:
+                    msg = "file not found, use -vvvvv to see paths searched"
+                    raise AnsibleOptionsError(msg)
+
+            except AnsibleError as e:
+                msg = f"The 'file' lookup had an issue accessing the file '{term}'"
+                raise AnsibleLookupError(msg, orig_exc=e)  # noqa: B904
+
+        return ret
diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py
index 0d4acb6..d4c8d28 100644
--- a/plugins/module_utils/common.py
+++ b/plugins/module_utils/common.py
@@ -9,7 +9,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING, Any, cast
 
 import yaml
-from ansible_collections.snailed.ez_compose.plugins.module_utils.models import Result, State
+from ansible_collections.snailed.ez_docker.plugins.module_utils.models import Result, State
 
 if TYPE_CHECKING:
     from ansible.module_utils.basic import AnsibleModule  # pyright: ignore[reportMissingTypeStubs]
diff --git a/plugins/module_utils/label/common.py b/plugins/module_utils/label/common.py
index f67f3e3..36532e4 100644
--- a/plugins/module_utils/label/common.py
+++ b/plugins/module_utils/label/common.py
@@ -5,13 +5,13 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any, Callable
 
-from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
+from ansible_collections.snailed.ez_docker.plugins.module_utils.common import (
     clean_none,
     recursive_update,
 )
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 
 BASE_ARGS: dict[str, Any] = {}
diff --git a/plugins/module_utils/label/docker_volume_backupper.py b/plugins/module_utils/label/docker_volume_backupper.py
index 549fa5b..b8575d5 100644
--- a/plugins/module_utils/label/docker_volume_backupper.py
+++ b/plugins/module_utils/label/docker_volume_backupper.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "stop": {"type": "bool", "default": True},
diff --git a/plugins/module_utils/label/traefik_middleware.py b/plugins/module_utils/label/traefik_middleware.py
index 248c7f4..2d614cf 100644
--- a/plugins/module_utils/label/traefik_middleware.py
+++ b/plugins/module_utils/label/traefik_middleware.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "proxy_type": {"type": "str", "default": "http"},
diff --git a/plugins/module_utils/label/traefik_router.py b/plugins/module_utils/label/traefik_router.py
index 9de64c8..5d83a18 100644
--- a/plugins/module_utils/label/traefik_router.py
+++ b/plugins/module_utils/label/traefik_router.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "name": {"type": "str"},
diff --git a/plugins/module_utils/label/traefik_service.py b/plugins/module_utils/label/traefik_service.py
index 110a611..7355015 100644
--- a/plugins/module_utils/label/traefik_service.py
+++ b/plugins/module_utils/label/traefik_service.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "proxy_type": {"type": "str", "default": "http"},
diff --git a/plugins/module_utils/service/common.py b/plugins/module_utils/service/common.py
index 8d224fc..4038c7c 100644
--- a/plugins/module_utils/service/common.py
+++ b/plugins/module_utils/service/common.py
@@ -7,13 +7,13 @@ import copy
 from dataclasses import replace
 from typing import TYPE_CHECKING, Any, Callable
 
-from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
+from ansible_collections.snailed.ez_docker.plugins.module_utils.common import (
     clean_none,
     recursive_update,
 )
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 BASE_ARGS: dict[str, Any] = {
     "name": {"type": "str"},
diff --git a/plugins/module_utils/service/custom.py b/plugins/module_utils/service/custom.py
index 223db4f..7cc927b 100644
--- a/plugins/module_utils/service/custom.py
+++ b/plugins/module_utils/service/custom.py
@@ -5,11 +5,11 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any
 
-from ansible_collections.snailed.ez_compose.plugins.module_utils import label, spec
-from ansible_collections.snailed.ez_compose.plugins.module_utils.common import clean_none
+from ansible_collections.snailed.ez_docker.plugins.module_utils import label, spec
+from ansible_collections.snailed.ez_docker.plugins.module_utils.common import clean_none
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import (
         State,
     )
 
diff --git a/plugins/module_utils/service/docker_in_docker.py b/plugins/module_utils/service/docker_in_docker.py
index 83ca103..f351cad 100644
--- a/plugins/module_utils/service/docker_in_docker.py
+++ b/plugins/module_utils/service/docker_in_docker.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {}
 
diff --git a/plugins/module_utils/service/docker_socket_proxy.py b/plugins/module_utils/service/docker_socket_proxy.py
index 22477a5..1f23ccc 100644
--- a/plugins/module_utils/service/docker_socket_proxy.py
+++ b/plugins/module_utils/service/docker_socket_proxy.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "read_only": {"type": "bool", "default": True},
diff --git a/plugins/module_utils/service/docker_volume_backupper.py b/plugins/module_utils/service/docker_volume_backupper.py
index 46735ff..4dc99c3 100644
--- a/plugins/module_utils/service/docker_volume_backupper.py
+++ b/plugins/module_utils/service/docker_volume_backupper.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "archive": {"type": "path"},
diff --git a/plugins/module_utils/service/mariadb.py b/plugins/module_utils/service/mariadb.py
index ddc32e9..eee0152 100644
--- a/plugins/module_utils/service/mariadb.py
+++ b/plugins/module_utils/service/mariadb.py
@@ -7,7 +7,7 @@ import shlex
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "backup": {"type": "bool", "default": True},
diff --git a/plugins/module_utils/service/postgres.py b/plugins/module_utils/service/postgres.py
index b2d172f..1ef378c 100644
--- a/plugins/module_utils/service/postgres.py
+++ b/plugins/module_utils/service/postgres.py
@@ -7,7 +7,7 @@ import shlex
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {
     "backup": {"type": "bool", "default": True},
diff --git a/plugins/module_utils/service/redis.py b/plugins/module_utils/service/redis.py
index d5cdf57..b3f62bf 100644
--- a/plugins/module_utils/service/redis.py
+++ b/plugins/module_utils/service/redis.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
+    from ansible_collections.snailed.ez_docker.plugins.module_utils.models import State
 
 EXTRA_ARGS = {}
 
diff --git a/plugins/module_utils/spec.py b/plugins/module_utils/spec.py
index bec0861..106fee9 100644
--- a/plugins/module_utils/spec.py
+++ b/plugins/module_utils/spec.py
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any
 if TYPE_CHECKING:
     from types import ModuleType
 
-from ansible_collections.snailed.ez_compose.plugins.module_utils import (
+from ansible_collections.snailed.ez_docker.plugins.module_utils import (
     label,
     service,
 )
diff --git a/plugins/modules/compose.py b/plugins/modules/compose.py
index 921666e..9e6f76a 100755
--- a/plugins/modules/compose.py
+++ b/plugins/modules/compose.py
@@ -9,6 +9,7 @@ from dataclasses import asdict
 
 # TODO: break this down per module
 # TODO: generate this by reassembling
+# TODO: add note about not setting container_name or host_name in defaults
 DOCUMENTATION = """
 ---
 module: compose
@@ -456,7 +457,7 @@ options:
 from importlib.util import find_spec
 
 from ansible.module_utils.basic import AnsibleModule  # pyright: ignore[reportMissingTypeStubs]
-from ansible_collections.snailed.ez_compose.plugins.module_utils import (
+from ansible_collections.snailed.ez_docker.plugins.module_utils import (
     common,
     service,
     spec,
@@ -473,7 +474,7 @@ def main() -> None:
             },
             "project_dir": {
                 "type": "path",
-                "default": "/var/lib/ez_compose",
+                "default": "/var/lib/ez_docker",
             },
             "settings": spec.settings_spec(),
             "services": spec.service_argument_spec(),
diff --git a/pyproject.toml b/pyproject.toml
index 439f0c0..ff6629f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,18 +14,13 @@ ignore = [
     "COM812",
     "D100",
     "D101",
+    "D102",
     "D103",
     "D104",
     "D203",
     "D213",
 ]
 
-[tool.ruff.lint.per-file-ignores]
-"tests/units/**" = [
-    "S101",
-    "PT009",
-]
-
 [tool.ruff.format]
 line-ending = "lf"
 
diff --git a/roles/compose/tasks/main.yml b/roles/compose/tasks/main.yml
deleted file mode 100644
index c68a0b1..0000000
--- a/roles/compose/tasks/main.yml
+++ /dev/null
@@ -1,74 +0,0 @@
----
-- name: Login in to registries
-  community.docker.docker_login:
-    registry_url: "{{ item.registry }}"
-    username: "{{ item.username }}"
-    password: "{{ item.password }}"
-  loop: "{{ compose_registry_logins }}"
-  no_log: true
-  when: >-
-    ez_compose_state | default(None) == "present"
-    and ez_compose_registry_logins is defined
-    and ez_compose_registry_logins
-
-- name: Ensure shared networks exist
-  community.docker.docker_network:
-    name: "{{ item.name }}"
-    attachable: "{{ item.attachable | default(omit) }}"
-    connected: "{{ item.connected | default(omit) }}"
-    driver: "{{ item.driver | default(omit) }}"
-    driver_options: "{{ item.driver_options | default(omit) }}"
-    enable_ipv6: "{{ item.enable_ipv6 | default(omit) }}"
-    force: "{{ item.force | default(omit) }}"
-    ipam_config: "{{ item.ipam_config | default(omit) }}"
-    ipam_driver: "{{ item.ipam_driver | default(omit) }}"
-    labels: "{{ item.labels | default(omit) }}"
-    scope: "{{ item.scope | default(omit) }}"
-    state: "{{ item.state | default(omit) }}"
-  loop: "{{ ez_compose_shared_networks }}"
-  when: >-
-    ez_compose_state | default(None) == "present"
-    and ez_compose_shared_networks is defined
-    and ez_compose_shared_networks
-
-- name: Ensure shared volumes exist
-  community.docker.docker_network:
-    name: "{{ item.name }}"
-    driver: "{{ item.driver | default(omit) }}"
-    driver_options: "{{ item.driver_options | default(omit) }}"
-    labels: "{{ item.labels | default(omit) }}"
-    recreate: "{{ item.recreate | default(omit) }}"
-    state: "{{ item.state | default(omit) }}"
-  loop: "{{ ez_compose_shared_volumes }}"
-  when: >-
-    ez_compose_state | default(None) == "present"
-    and ez_compose_shared_volumes is defined
-    and ez_compose_shared_volumes
-
-- name: Discover project definitions
-  ansible.builtin.set_fact:
-    ez_compose_projects: >-
-      {{
-        hostvars[inventory_hostname]
-        | dict2items
-        | selectattr(
-          'key', 'in', (
-            hostvars[inventory_hostname].keys()
-            | select('match', '^ez_compose_project_')
-          )
-        )
-      }}
-  when: >-
-    ez_compose_projects is not defined
-    or not ez_compose_projects
-
-- name: Import project tasks
-  ansible.builtin.include_tasks: project.yml
-  when: >-
-    [project.key | split('_') | last, 'compose-all'] | intersect(ansible_run_tags) | length > 0
-    and not
-    [project.key | split('_') | last, 'compose-all'] | intersect(ansible_skip_tags) | length > 0
-  loop: "{{ ez_compose_projects }}"
-  loop_control:
-    loop_var: project
-  # no_log: true
diff --git a/roles/compose/tasks/project.yml b/roles/compose/tasks/project.yml
deleted file mode 100644
index b54279c..0000000
--- a/roles/compose/tasks/project.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-- name: "project | Write compose file: {{ project.name }}"
-  snailed.ez_compose.compose:
-    name: "{{ project.key | split('_') | last }}"
-    services: "{{ project.value }}"
-    settings: "{{ ez_compose_settings }}"
-    project_dir: "{{ ez_compose_project_dir }}"
diff --git a/roles/ez_docker/default/main.yml b/roles/ez_docker/default/main.yml
new file mode 100644
index 0000000..61ab360
--- /dev/null
+++ b/roles/ez_docker/default/main.yml
@@ -0,0 +1,49 @@
+---
+# Registries to log in to with community.docker.docker_login
+ez_docker_registry_logins: []
+# - username: "example"
+#   password: "example"
+#   registry_url: "example.registry.url"
+
+# A list of docker compose network definitions to create with
+# community.docker.docker_network and mark as external in projects
+ez_docker_shared_networks: []
+# - name: "shared_network"
+
+# A list of docker compose volume definitions to create with
+# community.docker.docker_volume and mark as external in projects
+ez_docker_shared_volumes: []
+# - name: "shared_volume"
+
+# Directory to put compose projects into
+ez_docker_project_dir: "/var/lib/ez_compose"
+
+# Settings for the module
+ez_docker_settings:
+  default_definition: {}
+  # environment:
+  #   TZ: "Europe/Berlin"
+  label_default_args: {}
+  # traefik_router:
+  #   certresolver: "letsencrypt"
+  #   entrypoints: ["web-secure"]
+  service_default_definitions: {}
+  # docker_volume_backupper:
+  #   image: "offen/docker-volume-backup:v2.43.0"
+  #   environment:
+  #     BACKUP_CRON_EXPRESSION: "0 6 * * *"
+  #     BACKUP_RETENTION_DAYS: "7"
+  service_default_args: {}
+  # docker_volume_backupper:
+  #   archive: "/tank/docker-backups"
+#
+ez_cp_controller_files_root: "{{ playbook_dir }}/files/ez_cp/{{ inventory_hostname }}"
+ez_cp_controller_templates_root: "{{ playbook_dir }}/templates/ez_cp/{{ inventory_hostname }}"
+
+# To copy into a non-running container we need the UID/GID for the files
+# TODO: implement this
+ez_cp_service_owners: {}
+# project_name:
+#   service_name:
+#     uid: 1000
+#     gid: 1000
diff --git a/roles/ez_docker/tasks/main.yml b/roles/ez_docker/tasks/main.yml
new file mode 100644
index 0000000..d91396e
--- /dev/null
+++ b/roles/ez_docker/tasks/main.yml
@@ -0,0 +1,38 @@
+---
+- name: Discover project definitions
+  ansible.builtin.set_fact:
+    ez_docker_projects: >-
+      {{
+        hostvars[inventory_hostname]
+        | dict2items
+        | selectattr(
+          'key', 'in', (
+            hostvars[inventory_hostname].keys()
+            | select('match', '^ez_docker_project_')
+          )
+        )
+      }}
+  when: not ez_docker_projects
+
+- name: Login in to registries
+  community.docker.docker_login: "{{ item }}" # noqa: args[module]
+  loop: "{{ ez_docker_registry_logins }}"
+  no_log: true
+  when: ez_docker_registry_logins
+
+- name: Ensure shared networks exist # noqa: args[module]
+  community.docker.docker_network: "{{ item }}"
+  loop: "{{ ez_docker_shared_networks }}"
+  when: ez_docker_shared_networks
+
+- name: Ensure shared volumes exist # noqa: args[module]
+  community.docker.docker_volume: "{{ item }}"
+  loop: "{{ ez_docker_shared_volumes }}"
+  when: ez_docker_shared_volumes
+
+- name: Import per-project tasks
+  ansible.builtin.include_tasks: project.yml
+  loop: "{{ ez_docker_projects }}"
+  loop_control:
+    loop_var: project_definition
+  no_log: true
diff --git a/roles/ez_docker/tasks/project.yml b/roles/ez_docker/tasks/project.yml
new file mode 100644
index 0000000..6f26362
--- /dev/null
+++ b/roles/ez_docker/tasks/project.yml
@@ -0,0 +1,27 @@
+---
+- name: "project | Write compose files: {{ project_definition.key }}"
+  snailed.ez_docker.compose:
+    name: "{{ project_definition.key | split('_') | last }}"
+    services: "{{ project_definition.value }}"
+    settings: "{{ ez_docker_settings }}"
+    project_dir: "{{ ez_docker_project_dir }}"
+  no_log: true
+
+- name: "project | Start project: {{ project.name }}"
+  community.docker.docker_compose_v2:
+    pull: missing
+    project_src: "{{ ez_docker_project_dir }}/{{ project_definition.key }}"
+    state: "present"
+
+- name: "project | Import per-service tasks: {{ project_definition.key }}"
+  ansible.builtin.include_tasks: service.yml
+  with_snailed.ez_docker.container_names: "{{ project_definition }}"
+  loop_control:
+    loop_var: service
+
+- name: "project | Restart docker projects: {{ project_definition.key }}"
+  community.docker.docker_compose_v2:
+    services: services_changed
+    project_src: "{{ ez_docker_project_dir }}/{{ project_definition.key }}"
+    state: "restarted"
+  when: services_changed
diff --git a/roles/ez_docker/tasks/service.yml b/roles/ez_docker/tasks/service.yml
new file mode 100644
index 0000000..ae15f25
--- /dev/null
+++ b/roles/ez_docker/tasks/service.yml
@@ -0,0 +1,34 @@
+---
+- name: "service | Copy files into container: {{ service.key }}"
+  community.docker.docker_container_copy_into:
+    container: "{{ service.value }}"
+    container_path: "/{{ item.path }}"
+    content: >-
+      {{ snailed.ez_docker.slurp(
+        ez_cp_controller_files_root ~ "/" ~ project.key ~ "/" ~ service.key ~ "/" ~ item.path
+      ) }}
+    content_is_b64: true
+    mode: "{{ item.mode | int(base=8) }}"
+  when: item.state == "file"
+  with_community.general.filetree:
+    - "{{ ez_cp_controller_files_root }}/{{ project.key }}/{{ service.key }}"
+  register: copy
+
+- name: "service | Template files into container: {{ service.key }}"
+  community.docker.docker_container_copy_into:
+    container: "{{ service.value }}"
+    container_path: "/{{ item.path }}"
+    content: >-
+      {{ ansible.builtin.template(
+        ez_cp_controller_templates_root ~ "/" ~ project.key ~ "/" ~ service.key ~ "/" ~ item.path,
+        convert_data=false
+      ) }}
+    mode: "{{ item.mode | int(base=8) }}"
+  when: item.state == "file"
+  with_community.general.filetree:
+    - "{{ ez_cp_controller_templates_root }}/{{ project.key }}/{{ service.key }}"
+  register: template
+
+- name: "service | Register file changes: {{ service.key }}"
+  ansible.builtin.set_fact:
+    services_changed: "{{ services_changed | default([]) + [service.key] }}"
diff --git a/test.json b/test.json
index c672db6..9388faf 100644
--- a/test.json
+++ b/test.json
@@ -23,7 +23,6 @@
           "label_helpers": {
             "docker_volume_backupper": {},
             "traefik_router": {
-              "entrypoints": ["web-secure"],
               "rule": "Host(`taskwarrior-sync.snailed.de`)"
             }
           },
@@ -44,7 +43,8 @@
       },
       "label_default_args": {
         "traefik_router": {
-          "certresolver": "letsencrypt"
+          "certresolver": "letsencrypt",
+          "entrypoints": ["web-secure"]
         }
       },
       "service_default_args": {