diff --git a/etcd/defaults/main.yml b/etcd/defaults/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4d2f6a4b710000e6865de9335b1e5a21c378fe7b
--- /dev/null
+++ b/etcd/defaults/main.yml
@@ -0,0 +1,41 @@
+---
+
+etcd_manage_certs: false
+etcd_ssl: "{{ etcd_manage_certs }}"
+
+# etcd_ca_private_key_file: {{ inventory_dir }}/etcd_ca_key.pem
+# etcd_ca_private_key_passphrase: hunter2
+# etcd_ca_certificate_file: {{ inventory_dir }}/etcd_ca_cert.pem
+
+etcd_listen_peer_url: >-
+  http{{ 's' if etcd_ssl }}://{{
+  lookup('community.general.dig', ansible_fqdn) }}:2380
+etcd_listen_peer_urls:
+  - "{{ etcd_listen_peer_url }}"
+etcd_listen_client_url: >-
+  http{{ 's' if etcd_ssl }}://{{
+  lookup('community.general.dig', ansible_fqdn) }}:2379
+etcd_listen_client_urls:
+  - "{{ etcd_listen_client_url }}"
+etcd_initial_advertise_peer_urls: "{{ etcd_listen_peer_urls }}"
+etcd_advertise_client_urls: "{{ etcd_listen_client_urls }}"
+
+etcd_member_url_format: "http{{ 's' if etcd_ssl }}://%s:2380"
+
+# Set etcd_members to override automatic construction based on
+# `etcd_member_url_format` and `ansible_play_hosts`
+
+# etcd_members:
+#   a: https://10.0.0.1:2380
+#   b: https://10.0.0.2:2380
+#   c: https://10.0.0.3:2380
+
+# Set `etcd_initial_cluster_token` to override automatic generation based on
+# hashing `etcd_members`.
+
+# etcd_initial_cluster_token: etcd-cluster
+
+# Arbitrary extra config variables
+
+# etcd_extra:
+#   wal_dir: /srv/ssd/etcd-wal
diff --git a/etcd/handlers/main.yml b/etcd/handlers/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..02955f81ea0f9935b31d94d3024dc6284f3328b1
--- /dev/null
+++ b/etcd/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+
+- name: Restart etcd
+  ansible.builtin.systemd:
+    name: etcd.service
+    state: restarted
diff --git a/etcd/tasks/certs.yml b/etcd/tasks/certs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4fc3d5f9fa8ec3a5a46320fc8e3ce91c6af7ef32
--- /dev/null
+++ b/etcd/tasks/certs.yml
@@ -0,0 +1,159 @@
+---
+
+- name: Check and configure CA
+  run_once: true
+  delegate_to: localhost
+  block:
+    - name: Check that CA private key exists
+      ansible.builtin.assert:
+        that:
+          - etcd_ca_private_key_file is defined
+          - etcd_ca_private_key_passphrase
+        fail_msg: >-
+          Please ensure `etcd_ca_private_key_file` points to a suitable private
+          key for the CA and `etcd_ca_private_key_passphrase` contains its
+          passphrase.
+        quiet: true
+
+    - name: Check that CA private key is usable
+      community.crypto.openssl_privatekey_info:
+        path: "{{ etcd_ca_private_key_file }}"
+        passphrase: "{{ etcd_ca_private_key_passphrase }}"
+      register: ca_privkey_check
+      failed_when: ca_privkey_check.failed or not ca_privkey_check.can_parse_key
+
+    - name: Check if CA certificate exists
+      community.crypto.x509_certificate_info:
+        path: "{{ etcd_ca_certificate_file }}"
+      register: ca_cert_exists
+      ignore_errors: true
+
+    - name: Create CA
+      when: ca_cert_exists.failed or ca_cert_exists.expired
+      block:
+        - name: Create CA CSR temporary file
+          ansible.builtin.tempfile: {}
+          register: tempfile
+
+        - name: Create CA CSR
+          community.crypto.openssl_csr:
+            common_name: etcd CA
+            basic_constraints_critical: true
+            basic_constraints:
+              - "CA:TRUE"
+            create_subject_key_identifier: true
+            key_usage_critical: true
+            key_usage:
+              - Certificate Sign
+              - CRL Sign
+            name_constraints_critical: true
+            name_constraints_excluded: >-
+              {{ etcd_ca_name_constraints_excluded | default(omit) }}
+            name_constraints_permitted: >-
+              {{ etcd_ca_name_constraints_permitted | default(omit) }}
+            path: "{{ tempfile.path }}"
+            privatekey_path: "{{ etcd_ca_private_key_file }}"
+            privatekey_passphrase: "{{ etcd_ca_private_key_passphrase }}"
+            use_common_name_for_san: false
+
+        - name: Create CA certificate
+          community.crypto.x509_certificate:
+            csr_path: "{{ tempfile.path }}"
+            path: "{{ etcd_ca_certificate_file }}"
+            privatekey_path: "{{ etcd_ca_private_key_file }}"
+            privatekey_passphrase: "{{ etcd_ca_private_key_passphrase }}"
+            provider: selfsigned
+
+      always:
+        - name: Delete CSR
+          ansible.builtin.file:
+            path: "{{ tempfile.path }}"
+            state: absent
+
+- name: Install CA certificate
+  ansible.builtin.copy:
+    src: "{{ etcd_ca_certificate_file }}"
+    dest: /var/lib/etcd/ca.pem
+    owner: root
+    group: root
+    mode: "0644"
+
+- name: Create node certificate private key
+  community.crypto.openssl_privatekey:
+    path: /var/lib/etcd/node_priv.pem
+    owner: root
+    group: etcd
+    mode: "0640"
+    type: Ed25519
+
+- name: Check if node certificate exists
+  community.crypto.x509_certificate_info:
+    path: /var/lib/etcd/node_cert.pem
+  register: node_cert_exists
+  ignore_errors: true
+
+- name: Create node certificate
+  when: node_cert_exists.failed or node_cert_exists.expired
+  block:
+    - name: Create node CSR temporary file
+      ansible.builtin.tempfile: {}
+      register: tempfile
+
+    - name: Create node CSR
+      community.crypto.openssl_csr:
+        common_name: "{{ ansible_fqdn }}"
+        basic_constraints_critical: true
+        basic_constraints:
+          - "CA:FALSE"
+        create_subject_key_identifier: true
+        extended_key_usage:
+          - TLS Web Server Authentication
+          - TLS Web Client Authentication
+        key_usage_critical: true
+        key_usage:
+          - Digital Signature
+          - Key Encipherment
+        path: "{{ tempfile.path }}"
+        privatekey_path: /var/lib/etcd/node_priv.pem
+        subject_alt_name: >-
+          {{ (ansible_all_ipv4_addresses|map("regex_replace", "^", "IP:")|list)
+           + (ansible_all_ipv6_addresses|map("regex_replace", "^", "IP:")|list)
+           + ["DNS:%s" % ansible_fqdn, "DNS:%s" % ansible_hostname]
+          }}
+        return_content: true
+      register: node_csr
+
+    - name: Create temporary file for node certificate
+      ansible.builtin.tempfile:
+        suffix: "{{ inventory_hostname }}"
+      register: cert_tempfile
+
+    - name: Create node certificate
+      delegate_to: localhost
+      community.crypto.x509_certificate:
+        csr_content: "{{ node_csr.csr }}"
+        path: "{{ cert_tempfile.path }}"
+        provider: ownca
+        ownca_path: "{{ etcd_ca_certificate_file }}"
+        ownca_privatekey_path: "{{ etcd_ca_private_key_file }}"
+        ownca_privatekey_passphrase: "{{ etcd_ca_private_key_passphrase }}"
+
+    - name: Install certificate
+      ansible.builtin.copy:
+        src: "{{ cert_tempfile.path }}"
+        dest: /var/lib/etcd/node_cert.pem
+        owner: root
+        group: etcd
+        mode: "0640"
+
+  always:
+    - name: Delete temporary certificate file
+      ansible.builtin.file:
+        path: "{{ cert_tempfile.path }}"
+        state: absent
+      delegate_to: localhost
+
+    - name: Delete CSR
+      ansible.builtin.file:
+        path: "{{ tempfile.path }}"
+        state: absent
diff --git a/etcd/tasks/main.yml b/etcd/tasks/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cd89125f4fed29e69fc279db6b44bcae896dd2c9
--- /dev/null
+++ b/etcd/tasks/main.yml
@@ -0,0 +1,50 @@
+---
+
+- name: Install etcd
+  ansible.builtin.apt:
+    name:
+      - etcd-client
+      - etcd-server
+    policy_rc_d: 101  # do not start service
+
+- name: Setup certificates
+  ansible.builtin.include_tasks: certs.yml
+  when: etcd_manage_certs
+
+- name: Set etcd cluster members and peer URLs fact
+  ansible.builtin.set_fact:
+    etcd_members: >-
+      {{ dict(ansible_play_hosts | zip(values_list)) }}
+  vars:
+    # Following ansible-lint would worsen readability, because it does not read
+    # the `>-` multiline string as such and complains about a space that is
+    # actually a newline.
+    # noqa: jinja[spacing]
+    values_list: >-
+      [{% for h in ansible_play_hosts -%}
+      "{{ etcd_member_url_format | format(h) }}"
+      {%- if not loop.last %},{% endif -%}
+      {%- endfor %}]
+  when: etcd_members is not defined
+
+- name: Set etcd initial cluster token
+  ansible.builtin.set_fact:
+    etcd_initial_cluster_token: >-
+      {{ etcd_members | dictsort | to_json | hash("md5") }}
+  when: etcd_initial_cluster_token is not defined
+
+- name: Configure etcd
+  ansible.builtin.template:
+    src: environment.j2
+    dest: /etc/default/etcd
+    owner: root
+    group: root
+    mode: "0640"
+  notify:
+    - Restart etcd
+
+- name: Enable and start etcd
+  ansible.builtin.systemd:
+    name: etcd.service
+    enabled: true
+    state: started
diff --git a/etcd/templates/environment.j2 b/etcd/templates/environment.j2
new file mode 100644
index 0000000000000000000000000000000000000000..21f6dd96e8bba1cb86e957a2ebf308fb5fa748ce
--- /dev/null
+++ b/etcd/templates/environment.j2
@@ -0,0 +1,23 @@
+ETCD_NAME="{{ inventory_hostname }}"
+ETCD_LISTEN_PEER_URLS="{{ etcd_listen_peer_urls | join(',') }}"
+ETCD_LISTEN_CLIENT_URLS="{{ etcd_listen_client_urls | join(',') }}"
+ETCD_INITIAL_ADVERTISE_PEER_URLS="{{ etcd_initial_advertise_peer_urls|join(',') }}"
+ETCD_INITIAL_CLUSTER="{% for k, v in etcd_members.items() -%}
+{{ k }}={{ v }}
+{%- if not loop.last %},{% endif -%}
+{%- endfor %}"
+{# Scaling not yet supported. #}
+ETCD_INITIAL_CLUSTER_STATE="new"
+ETCD_INITIAL_CLUSTER_TOKEN="{{ etcd_initial_cluster_token }}"
+ETCD_ADVERTISE_CLIENT_URLS="{{ etcd_advertise_client_urls | join(',') }}"
+{% if etcd_ssl %}
+ETCD_KEY_FILE="/var/lib/etcd/node_priv.pem"
+ETCD_CERT_FILE="/var/lib/etcd/node_cert.pem"
+ETCD_PEER_TRUSTED_CA_FILE="/var/lib/etcd/ca.pem"
+ETCD_PEER_KEY_FILE="/var/lib/etcd/node_priv.pem"
+ETCD_PEER_CERT_FILE="/var/lib/etcd/node_cert.pem"
+ETCD_PEER_CLIENT_CERT_AUTH="true"
+{% endif %}
+{% for k, v in etcd_extra | default({}) | items %}
+ETCD_{{ k | upper }}="{{ v }}"
+{% endfor %}