diff --git a/host_vars/orion.archlinux.org/misc b/host_vars/orion.archlinux.org/misc
index f262ca79fc9e5d20fdbdbd60e4f8009c9c43cacc..a820a9a0177b894b9c8fe191c24cf1409915132a 100644
--- a/host_vars/orion.archlinux.org/misc
+++ b/host_vars/orion.archlinux.org/misc
@@ -22,3 +22,8 @@ zabbix_agent_templates:
   - Template App Borg Backup
   - Template App Nginx
   - Template App Archive
+
+fail2ban_jails:
+  sshd: true
+  postfix: true
+  dovecot: true
diff --git a/playbooks/orion.yml b/playbooks/orion.yml
index 54ebd068323c69362d4aa096785bcc10a29bc63f..428affafeace7ba23dbc7518cd2cdea223b7a952 100644
--- a/playbooks/orion.yml
+++ b/playbooks/orion.yml
@@ -29,3 +29,4 @@
     - { role: archive, archive_domain: "archive.archlinux.org", archive_dir: "/srv/archive", tags: ['archive'] }
     - { role: hefur, ftp_iso_dir: '/srv/ftp/iso', tags: ['torrenttracker']}
     - wkd
+    - { role: fail2ban, tags: ["fail2ban"] }
diff --git a/roles/archwiki/tasks/main.yml b/roles/archwiki/tasks/main.yml
index 286a0955d5876d6ed75126cddbce5c4da5142120..25918005da07f63bf62be779bb87684499cccfbe 100644
--- a/roles/archwiki/tasks/main.yml
+++ b/roles/archwiki/tasks/main.yml
@@ -99,6 +99,16 @@
     - archwiki-question-updater.service
     - archwiki-memcached.service
 
+- name: install fail2ban rate-limit filter
+  template: src=fail2ban.filter.j2 dest=/etc/fail2ban/filter.d/nginx-dos.local owner=root group=root mode=0644
+  tags:
+    - fail2ban
+
+- name: install fail2ban rate-limit jail
+  template: src=fail2ban.jail.j2 dest=/etc/fail2ban/jail.d/nginx-dos.local owner=root group=root mode=0644
+  tags:
+    - fail2ban
+
 - name: start and enable archwiki runjobs timer
   service: name="archwiki-runjobs.timer" enabled=yes state=started
 
diff --git a/roles/fail2ban/templates/nginx-dos.conf b/roles/archwiki/templates/fail2ban.filter.j2
similarity index 91%
rename from roles/fail2ban/templates/nginx-dos.conf
rename to roles/archwiki/templates/fail2ban.filter.j2
index 56a5ee4b327d725b445f68d63d28a482c8434273..7b637034bbdf3cdd578c6695215d7c65b8c47feb 100644
--- a/roles/fail2ban/templates/nginx-dos.conf
+++ b/roles/archwiki/templates/fail2ban.filter.j2
@@ -1,3 +1,7 @@
+#
+# {{ansible_managed}}
+#
+
 [Definition]
 # Option:  failregex
 # Notes.:  Regexp to catch a generic call from an IP address.
diff --git a/roles/fail2ban/defaults/main.yml b/roles/fail2ban/defaults/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..003ddc95b65a4843bbd64624258bebea59a1a5b7
--- /dev/null
+++ b/roles/fail2ban/defaults/main.yml
@@ -0,0 +1,14 @@
+# by default all jails are disabled
+# override this variable in a host/group file to define which jails to enable
+fail2ban_jails:
+  sshd: false
+  postfix: false
+  dovecot: false
+
+# use variables for these directives so they can be overridden at a host or
+# group level as required. note that there cannot be a space between the
+# integer and the unit (eg "15min" == good, "15 min" == bad).
+# refer to `man jail.conf`
+fail2ban_findtime: 15min
+fail2ban_bantime: 1day
+fail2ban_maxretry: 5
diff --git a/roles/fail2ban/handlers/main.yml b/roles/fail2ban/handlers/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..731c718ac6bfa06860e7b893cafd1eaf4d16631d
--- /dev/null
+++ b/roles/fail2ban/handlers/main.yml
@@ -0,0 +1,7 @@
+- name: restart fail2ban
+  systemd:
+    name: fail2ban
+    state: restarted
+
+- name: reload fail2ban jails
+  shell: type fail2ban-server > /dev/null && (fail2ban-client ping > /dev/null && fail2ban-client reload > /dev/null || true) || true
diff --git a/roles/fail2ban/tasks/main.yml b/roles/fail2ban/tasks/main.yml
index 4a726b0e3b22385fdd02b185547a4fefef8207fa..faca06bf603cf9f2be33a8ec80c08f01b34c9bfc 100644
--- a/roles/fail2ban/tasks/main.yml
+++ b/roles/fail2ban/tasks/main.yml
@@ -1,14 +1,77 @@
----
-
 - name: install fail2ban
-  pacman: name=fail2ban state=present
+  package:
+    name: "fail2ban"
+    state: "present"
+  notify:
+    - restart fail2ban
+
+- name: install systemd unit override file
+  template:
+    src: "fail2ban.service.j2"
+    dest: "/etc/systemd/system/fail2ban.service.d/override.conf"
+    owner: "root"
+    group: "root"
+    mode: 0644
+
+- name: install local config files
+  template:
+    src: "{{item}}.j2"
+    dest: "/etc/fail2ban/{{item}}"
+    owner: "root"
+    group: "root"
+    mode: 0644
+  with_items:
+    - "fail2ban.local"
+    - "jail.local"
+  notify:
+    - restart fail2ban
+
+- name: install firewallcmd-allports.local
+  template:
+    src: "firewallcmd-allports.local.j2"
+    dest: "/etc/fail2ban/action.d/firewallcmd-allports.local"
+    owner: "root"
+    group: "root"
+    mode: 0644
+  notify:
+    - restart fail2ban
 
-- name: install jail.local
-  template: src=jail.local.j2 dest=/etc/fail2ban/jail.local owner=root group=root mode=0644
+- name: install sshd jail
+  when: fail2ban_jails.sshd
+  template:
+    src: "sshd.jail.j2"
+    dest: "/etc/fail2ban/jail.d/sshd.local"
+    owner: "root"
+    group: "root"
+    mode: 0644
+  notify:
+    - reload fail2ban jails
 
-- name: install nginx-dos filter
-  template: src=nginx-dos.conf dest=/etc/fail2ban/filter.d/nginx-dos.conf owner=root group=root mode=0644
+- name: install postfix jail
+  when: fail2ban_jails.postfix
+  template:
+    src: "postfix.jail.j2"
+    dest: "/etc/fail2ban/jail.d/postfix.local"
+    owner: "root"
+    group: "root"
+    mode: 0644
+  notify:
+    - reload fail2ban jails
 
-- name: start and enable fail2ban service
-  service: name=fail2ban.service enabled=yes state=started
+- name: install dovecot jail
+  when: fail2ban_jails.dovecot
+  template:
+    src: "dovecot.jail.j2"
+    dest: "/etc/fail2ban/jail.d/dovecot.local"
+    owner: "root"
+    group: "root"
+    mode: 0644
+  notify:
+    - reload fail2ban jails
 
+- name: start and enable service
+  systemd:
+    name: "fail2ban.service"
+    enabled: yes
+    state: started
+    daemon-reload: yes
diff --git a/roles/fail2ban/templates/dovecot.jail.j2 b/roles/fail2ban/templates/dovecot.jail.j2
new file mode 100644
index 0000000000000000000000000000000000000000..80aae27d551e28c129ff2af6e9d1e092ea98ad1e
--- /dev/null
+++ b/roles/fail2ban/templates/dovecot.jail.j2
@@ -0,0 +1,8 @@
+#
+# {{ansible_managed}}
+#
+
+[dovecot]
+enabled = true
+findtime = 3600 ; 1 hour
+maxretry = 8
diff --git a/roles/fail2ban/templates/fail2ban.local.j2 b/roles/fail2ban/templates/fail2ban.local.j2
new file mode 100644
index 0000000000000000000000000000000000000000..6bf9be7eb5cbb3a88f6ff360ee41d427bbf48e6a
--- /dev/null
+++ b/roles/fail2ban/templates/fail2ban.local.j2
@@ -0,0 +1,12 @@
+#
+# {{ansible_managed}}
+#
+
+[Definition]
+
+# reduce logging verbosity by default; if we need to debug we can set it
+# temporarily higher by using: fail2ban-client set loglevel INFO
+loglevel = WARNING
+
+# we need to override the default pid path to /run instead of /var/run
+pidfile = /run/fail2ban/fail2ban.pid
diff --git a/roles/fail2ban/templates/fail2ban.service.j2 b/roles/fail2ban/templates/fail2ban.service.j2
new file mode 100644
index 0000000000000000000000000000000000000000..eb8761b99cffd282bf07f19ec5c4cb591e4f8197
--- /dev/null
+++ b/roles/fail2ban/templates/fail2ban.service.j2
@@ -0,0 +1,6 @@
+# the user journal files exceeds MaxNOFiles so increase the
+# maximum number of open files
+# Refer: https://github.com/fail2ban/fail2ban/issues/2208
+
+[Service]
+LimitNOFILE=8192
diff --git a/roles/fail2ban/templates/firewallcmd-allports.local.j2 b/roles/fail2ban/templates/firewallcmd-allports.local.j2
new file mode 100644
index 0000000000000000000000000000000000000000..26352a00adee5c10fe2291b0e23ba921d8e2c42a
--- /dev/null
+++ b/roles/fail2ban/templates/firewallcmd-allports.local.j2
@@ -0,0 +1,8 @@
+#
+# {{ansible_managed}}
+#
+
+# creates the requisite chains in firewalld when fail2ban starts instead
+# of creating them on first use (ie, when first IP is banned)
+[Definition]
+actionstart_on_demand = false
diff --git a/roles/fail2ban/templates/jail.local.j2 b/roles/fail2ban/templates/jail.local.j2
index dcacf0bc93d1ea32a1062df3671e29b104a6ab3e..1e68c2023f64b8722782922e3187869ac5f943c6 100644
--- a/roles/fail2ban/templates/jail.local.j2
+++ b/roles/fail2ban/templates/jail.local.j2
@@ -1,9 +1,30 @@
-[wiki-nginx-dos]
-enabled = true
-filter = nginx-dos
-action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
-logpath = /var/log/nginx/wiki.archlinux.org/access.log
-# 400 pages in 30 minutes
-findtime = 1800
-bantime = 1d
-maxretry = 400
+#
+# {{ansible_managed}}
+#
+
+[DEFAULT]
+findtime = {{fail2ban_findtime}}
+bantime  = {{fail2ban_bantime}}
+maxretry = {{fail2ban_maxretry}}
+
+# don't trust dns
+usedns = no
+
+# if f2b ever needs to send emails, send them to root and make sure the sender
+# address clearly identifies the host the message originated from
+destemail = root
+sender = fail2ban@{{ansible_fqdn}}
+
+# use firewalld to manage bans - if we don't specify this, then fail2ban will
+# default to use iptables, which we don't want as our systems are running
+# firewalld with nftables backend.
+#
+# check current rules added to firewalld while fail2ban is running:
+#   firewall-cmd --direct --get-all-rules
+# useful runtime commands include:
+#   fail2ban-client set <JAIL> banip <IP>
+#   fail2ban-cleint set <JAIL> unbanip <IP>
+#   fail2ban-client set unban <IP>
+#   fail2ban-client set unban --all
+# see `fail2ban-client help` for full list of runtime commands
+banaction = firewallcmd-allports
diff --git a/roles/fail2ban/templates/postfix.jail.j2 b/roles/fail2ban/templates/postfix.jail.j2
new file mode 100644
index 0000000000000000000000000000000000000000..4989375ff1eb1e60f893d8c31d4c21e71a70ef7e
--- /dev/null
+++ b/roles/fail2ban/templates/postfix.jail.j2
@@ -0,0 +1,9 @@
+#
+# {{ansible_managed}}
+#
+
+[postfix]
+mode = aggressive
+enabled = true
+findtime = 3600 ; 1 hour
+maxretry = 8
diff --git a/roles/fail2ban/templates/sshd.jail.j2 b/roles/fail2ban/templates/sshd.jail.j2
new file mode 100644
index 0000000000000000000000000000000000000000..d156d2989fbf080c251926ae1b36c655f4b8ccab
--- /dev/null
+++ b/roles/fail2ban/templates/sshd.jail.j2
@@ -0,0 +1,6 @@
+#
+# {{ansible_managed}}
+#
+
+[sshd]
+enabled = true