diff --git a/caddy/defaults/main.yml b/caddy/defaults/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c6be69a869ae9b03da6823f7b0ae1adc6140d56d
--- /dev/null
+++ b/caddy/defaults/main.yml
@@ -0,0 +1,39 @@
+---
+
+# caddy_global:
+#   auto_https: false
+#   email: acme@example.org
+#   on_demand_tls:
+#     ask: http://localhost:9123/ask
+#   debug: ""  # Special value-less option, use with empty string
+#   http_port: null  # Omit, useful to override in more specific variables
+
+caddy_local_sites: {}
+#   example.org:
+#     root *: /var/www
+#     file_server: ""
+
+caddy_enabled_sites: []
+#   - example.com  # Config filled in by some other role
+
+caddy_local_config: {}
+# cors:
+#   "(cors)":
+#     # Note the differences in quoting!  The next line will not contain quotes
+#     # in the resulting Caddyfile, the one after that _will_ end up with
+#     # quotes!
+#     "@origin header Origin": "{args[0]}"
+#     header @origin Access-Control-Allow-Origin: '"{args[0]}"'
+#     header @origin Access-Control-Allow-Methods: >-
+#       "OPTIONS,HEAD,GET,POST,PUT,PATCH,DELETE"
+# # Or just specify the contents verbatim:
+# https-proxy: |
+#   (https-proxy) {
+#   	reverse_proxy {args[:]} {
+#   		transport http {
+#   			tls
+#   		}
+#   	}
+#   }
+
+caddy_enabled_config: []
diff --git a/caddy/handlers/main.yml b/caddy/handlers/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..889b30b4691721c5fe9405f6ec9ad82de347e6f7
--- /dev/null
+++ b/caddy/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+
+- name: Reload Caddy
+  ansible.builtin.systemd:
+    name: caddy.service
+    state: reloaded
diff --git a/caddy/tasks/main.yml b/caddy/tasks/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7c6108388c013883046ee93f294f4ce77e17ca50
--- /dev/null
+++ b/caddy/tasks/main.yml
@@ -0,0 +1,169 @@
+---
+
+- name: Install Caddy
+  ansible.builtin.apt:
+    name: caddy
+  tags:
+    - caddy
+
+- name: Configure Caddy main configuration file
+  ansible.builtin.template:
+    src: Caddyfile.j2
+    dest: /etc/caddy/Caddyfile
+    validate: caddy validate --adapter caddyfile --config %s
+    owner: root
+    group: root
+    mode: "0644"
+  notify:
+    - Reload Caddy
+  tags:
+    - caddy
+    - config
+
+- name: Create drop-in configuration directories
+  ansible.builtin.file:
+    path: /etc/caddy/{{ item }}
+    state: directory
+    owner: root
+    group: root
+    mode: "0755"
+  loop:
+    - conf-available
+    - sites-available
+    - conf-enabled
+    - sites-enabled
+  tags:
+    - caddy
+
+- name: Configure Caddy site drop-ins
+  ansible.builtin.template:
+    src: site.j2
+    dest: /etc/caddy/sites-available/{{ item.key | urlencode }}
+    validate: caddy validate --adapter caddyfile --config %s
+    owner: root
+    group: root
+    mode: "0644"
+  loop: "{{ caddy_local_sites | dict2items }}"
+  vars:
+    site_name: "{{ item.key }}"
+    site_config: "{{ item.value }}"
+  notify:
+    - Reload Caddy
+  tags:
+    - caddy
+    - config
+
+- name: Configure Caddy config drop-ins
+  ansible.builtin.template:
+    src: conf.j2
+    dest: /etc/caddy/conf-available/{{ item.key | urlencode }}
+    validate: caddy validate --adapter caddyfile --config %s
+    owner: root
+    group: root
+    mode: "0644"
+  loop: "{{ caddy_local_config | dict2items }}"
+  vars:
+    config: "{{ item.value }}"
+  notify:
+    - Reload Caddy
+  tags:
+    - caddy
+    - config
+
+- name: Link active sites
+  ansible.builtin.file:
+    path: /etc/caddy/sites-enabled/{{ item | urlencode }}
+    src: ../sites-available/{{ item | urlencode }}
+    state: link
+  loop: "{{ caddy_local_sites | list + caddy_enabled_sites }}"
+  notify:
+    - Reload Caddy
+  tags:
+    - caddy
+    - config
+
+- name: Link active config drop-ins
+  ansible.builtin.file:
+    path: /etc/caddy/conf-enabled/{{ item | urlencode }}
+    src: ../conf-available/{{ item | urlencode }}
+    state: link
+  loop: "{{ caddy_local_config | list + caddy_enabled_config }}"
+  notify:
+    - Reload Caddy
+  tags:
+    - caddy
+    - config
+
+- name: Get active sites
+  ansible.builtin.find:
+    path: /etc/caddy/sites-enabled
+    pattern: "*"
+    file_type: link
+  register: current_active_sites
+  tags:
+    - caddy
+    - config
+
+- name: Disable inactive sites
+  ansible.builtin.file:
+    path: /etc/caddy/sites-enabled/{{ item }}
+    state: absent
+  loop: >-
+    {{ current_active_sites.files
+       | map(attribute='path')
+       | map('basename')
+       | map('splitext')
+       | map('first')
+       | difference(wanted_active_sites)
+    }}
+  loop_control:
+    label: "{{ item }}"
+  vars:
+    wanted_active_sites: >-
+      {{ (caddy_local_sites | list + caddy_enabled_sites)
+         | map('urlencode')
+      }}
+  tags:
+    - caddy
+    - config
+
+- name: Get active configuration drop-ins
+  ansible.builtin.find:
+    path: /etc/caddy/conf-enabled
+    pattern: "*"
+    file_type: link
+  register: current_active_config
+  tags:
+    - caddy
+    - config
+
+- name: Disable inactive configuration drop-ins
+  ansible.builtin.file:
+    path: /etc/caddy/conf-enabled/{{ item }}
+    state: absent
+  loop: >-
+    {{ current_active_config.files
+       | map(attribute='path')
+       | map('basename')
+       | map('splitext')
+       | map('first')
+       | difference(wanted_active_config)
+    }}
+  loop_control:
+    label: "{{ item }}"
+  vars:
+    wanted_active_config: >-
+      {{ (caddy_local_config | list + caddy_enabled_config)
+         | map('urlencode')
+      }}
+  tags:
+    - caddy
+    - config
+
+- name: Enable and start Caddy
+  ansible.builtin.systemd:
+    name: caddy.service
+    state: started
+    enabled: true
+  tags:
+    - caddy
diff --git a/caddy/templates/Caddyfile.j2 b/caddy/templates/Caddyfile.j2
new file mode 100644
index 0000000000000000000000000000000000000000..23d3f3ffea5aa0dac45909a6f0a361ff6c22844f
--- /dev/null
+++ b/caddy/templates/Caddyfile.j2
@@ -0,0 +1,9 @@
+{% from "_macros.j2" import option -%}
+{
+    {%- for k, v in caddy_global | items +%}
+    {{ option(k, v) | indent }}
+    {%- endfor +%}
+}
+
+import conf-enabled/*
+import sites-enabled/*
diff --git a/caddy/templates/_macros.j2 b/caddy/templates/_macros.j2
new file mode 100644
index 0000000000000000000000000000000000000000..12ab970004661e97ae015035e37e94295fd4586c
--- /dev/null
+++ b/caddy/templates/_macros.j2
@@ -0,0 +1,13 @@
+{% macro option(key, value) -%}
+{%- if value is mapping -%}
+{{ key }} {
+    {%- for skey, svalue in value | items +%}
+    {{ option(skey, svalue) | indent }}
+    {%- endfor +%}
+}
+{%- elif value is boolean -%}
+{{ key }} {{ "on" if value else "off" }}
+{%- elif value is not none -%}
+{{ key }} {{ value }}
+{%- endif -%}
+{%- endmacro -%}
diff --git a/caddy/templates/conf.j2 b/caddy/templates/conf.j2
new file mode 100644
index 0000000000000000000000000000000000000000..744a06e84dd67d0577ee9df93beb22b1cc030c51
--- /dev/null
+++ b/caddy/templates/conf.j2
@@ -0,0 +1,15 @@
+{% from "_macros.j2" import option -%}
+{% if config is mapping -%}
+{% for key, value in config | items -%}
+{{ key }} {
+    {%- if value is string %}
+    {{ value | indent }}
+    {%- endif %}
+    {%- for subkey, subvalue in value | items +%}
+    {{ option(subkey, subvalue) | indent }}
+    {%- endfor +%}
+}
+{% endfor %}
+{%- else -%}
+{{ config }}
+{%- endif %}
diff --git a/caddy/templates/site.j2 b/caddy/templates/site.j2
new file mode 100644
index 0000000000000000000000000000000000000000..529c12b5b4c8e0fc0051c2fa4286b9126b829182
--- /dev/null
+++ b/caddy/templates/site.j2
@@ -0,0 +1,6 @@
+{% from "_macros.j2" import option -%}
+{{ site_name }} {
+    {%- for k, v in site_config | items +%}
+    {{ option(k, v) | indent }}
+    {%- endfor +%}
+}