diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000000..32a8167ae9bb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,147 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 Robin Jarry
+#
+# Makefile for deploying pwforge + patchwork on a single host.
+# Run as root or with sudo.
+
+DOMAIN ?= patches.jarry.cc
+DB_NAME ?= patchwork
+DB_USER ?= patchwork
+DB_PASSWORD ?= $(shell openssl rand -base64 24)
+DJANGO_SECRET ?= $(shell openssl rand -base64 48)
+WEBHOOK_SECRET ?= $(shell openssl rand -base64 48)
+
+.PHONY: help
+help:
+ @echo "usage: make <target> [VAR=value ...]"
+ @echo ""
+ @echo "targets:"
+ @echo " deps install system packages"
+ @echo " database create postgresql database"
+ @echo " patchwork install patchwork"
+ @echo " uwsgi install uwsgi config and service"
+ @echo " postfix install postfix config"
+ @echo " nginx install nginx config and obtain TLS cert"
+ @echo " install install everything (idempotent)"
+ @echo ""
+ @echo "variables:"
+ @echo " DOMAIN $(DOMAIN)"
+ @echo " DB_NAME $(DB_NAME)"
+ @echo " DB_USER $(DB_USER)"
+ @echo " DB_PASSWORD (auto-generated if unset)"
+ @echo " DJANGO_SECRET (auto-generated if unset)"
+
+DEB := git nginx certbot python3-certbot-nginx postfix opendkim opendkim-tools
+DEB += uwsgi uwsgi-plugin-python3
+DEB += postgresql python3-psycopg2
+DEB += python3-django python3-djangorestframework python3-django-filters
+
+.PHONY: deps
+deps:
+ apt-get update -q
+ apt-get install -qy --no-install-recommends $(DEB)
+ usermod -a -G opendkim postfix
+
+/etc/default/patchwork:
+ @echo "DJANGO_SETTINGS_MODULE=patchwork.settings.production" > $@
+ @echo "DJANGO_SECRET_KEY=$(DJANGO_SECRET)" >> $@
+ @echo "DATABASE_NAME=$(DB_NAME)" >> $@
+ @echo "DATABASE_USER=$(DB_USER)" >> $@
+ @echo "DATABASE_PASSWORD=$(DB_PASSWORD)" >> $@
+ @echo "DATABASE_HOST=localhost" >> $@
+ @echo "DATABASE_PORT=" >> $@
+ @echo "STATIC_ROOT=/var/www/patchwork" >> $@
+ @echo "ALLOWED_HOSTS=$(DOMAIN)" >> $@
+ @echo "FROM_EMAIL='Patchwork <no-reply@$(DOMAIN)>'" >> $@
+ @echo "GH_WEBHOOK_SECRET=$(WEBHOOK_SECRET)" >> $@
+ @echo "GH_TOKEN=replace_me" >> $@
+ chmod 640 $@
+ chgrp www-data $@
+
+.PHONY: database
+database: /etc/default/patchwork
+ @set -e; . $<; \
+ user=`sudo -u postgres psql -tXAc "SELECT 1 FROM pg_roles WHERE rolname='$$DATABASE_USER';"`; \
+ if [ -z "$$user" ]; then \
+ sudo -u postgres psql -c "CREATE USER $$DATABASE_USER PASSWORD '$$DATABASE_PASSWORD';"; \
+ sudo -u postgres createdb -O $$DATABASE_USER $$DATABASE_NAME; \
+ fi
+
+.PHONY: patchwork
+patchwork: database
+ mkdir -p /var/cache/patchwork/mirrors
+ chmod 770 /var/cache/patchwork/mirrors
+ chgrp -R www-data /var/cache/patchwork/mirrors
+ mkdir -p /var/www/patchwork
+ set -a && . /etc/default/patchwork && set +a -xe && \
+ python3 manage.py migrate --noinput && \
+ python3 manage.py collectstatic --noinput && \
+ python3 manage.py loaddata default_tags default_states
+
+/etc/uwsgi/apps-available/patchwork.ini: uwsgi.ini
+ install -Dm 0644 $< $@
+ sed -i 's,^chdir.*,chdir = $(CURDIR),' $@
+
+/etc/uwsgi/apps-enabled/patchwork.ini: /etc/uwsgi/apps-available/patchwork.ini
+ ln -sfr $< $@
+
+/etc/systemd/system/%.service: %.service
+ install -Dm 0644 $< $@
+
+.PHONY: uwsgi
+uwsgi: /etc/uwsgi/apps-enabled/patchwork.ini /etc/systemd/system/patchwork.service
+
+/etc/postfix/%: postfix/%
+ install -D -m 0644 $< $@
+ sed -i 's/patches.example.com/$(DOMAIN)/g' $@
+ sed -i 's,/opt/patchwork,$(CURDIR),' $@
+
+/etc/dkimkeys/$(DOMAIN)/mail.private:
+ mkdir -p $(@D)
+ opendkim-genkey -d $(DOMAIN) -s mail -D $(@D)
+ chown -R opendkim:opendkim $(@D)
+ sed -Ei '/^(Domain|Selector|KeyFile)/d' /etc/opendkim.conf
+ echo 'Domain $(DOMAIN)' >> /etc/opendkim.conf
+ echo 'Selector mail' >> /etc/opendkim.conf
+ echo 'KeyFile $@' >> /etc/opendkim.conf
+
+.PHONY: postfix
+postfix: $(addprefix /etc/,$(wildcard postfix/*)) /etc/dkimkeys/$(DOMAIN)/mail.private
+ postmap /etc/postfix/transport
+ postmap /etc/postfix/recipient_bcc
+
+/etc/nginx/sites-enabled/$(DOMAIN): nginx.conf
+ @if ! [ -r "/etc/letsencrypt/live/$(DOMAIN)/cert.pem" ]; then \
+ set -xe; \
+ rm -f $@ /etc/nginx/sites-enabled/pw_; \
+ awk '/^server / {count++} count<2' $< | \
+ sed 's/patches.example.com/$(DOMAIN)/g' > \
+ /etc/nginx/sites-enabled/pw_; \
+ systemctl reload nginx; \
+ mkdir -p /var/www/acme; \
+ certbot certonly --webroot -w /var/www/acme -d $(DOMAIN); \
+ rm -f /etc/nginx/sites-enabled/pw_; \
+ fi
+ install -Dm 644 $< /etc/nginx/sites-available/$(DOMAIN)
+ sed -i 's/patches.example.com/$(DOMAIN)/g' /etc/nginx/sites-available/$(DOMAIN)
+ ln -sfr /etc/nginx/sites-available/$(DOMAIN) $@
+ nginx -t
+
+.PHONY: nginx
+nginx: /etc/nginx/sites-enabled/$(DOMAIN)
+
+.PHONY: install
+install: database patchwork uwsgi postfix nginx
+ systemctl daemon-reload
+ systemctl enable --now patchwork postfix nginx opendkim
+ systemctl restart patchwork postfix nginx opendkim
+ @echo
+ @echo "Update your DNS zone with the following entries:"
+ @echo
+ @ip -j addr | jq -r '.[].addr_info[] | select(.scope == "global" and .family == "inet") | .local' | \
+ sed 's/^/$(DOMAIN). 10800 IN A /'
+ @ip -j addr | jq -r '.[].addr_info[] | select(.scope == "global" and .family == "inet6") | .local' | \
+ sed 's/^/$(DOMAIN). 10800 IN AAAA /'
+ @echo "$(DOMAIN). 10800 IN MX 10 $(DOMAIN)."
+ @echo '$(DOMAIN). 10800 IN TXT "v=spf1 a -all"'
+ @sed -E 's/^(mail._domainkey)/\1.$(DOMAIN)./' /etc/dkimkeys/$(DOMAIN)/mail.txt
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 000000000000..1be8d5c1aabe
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,62 @@
+# /etc/nginx/sites-available/pwforge
+#
+# Reverse proxy for patchwork and pwforge behind nginx.
+# Use certbot to obtain TLS certificates:
+# certbot certonly --webroot -w /var/www/acme -d patches.example.com
+
+limit_req_zone $pw_limit zone=pw:10m rate=20r/s;
+
+# Only rate-limit write requests (POST, PUT, DELETE)
+map $request_method $pw_limit {
+ GET "";
+ HEAD "";
+ default $binary_remote_addr;
+}
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name patches.example.com;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/acme;
+ }
+ location / {
+ return 301 https://patches.example.com$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name patches.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/patches.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/patches.example.com/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ limit_req zone=pw burst=10 nodelay;
+
+ server_tokens off;
+
+ location = favicon.ico {
+ access_log off;
+ log_not_found off;
+ }
+
+ location /static {
+ alias /var/www/patchwork;
+ expires 3h;
+ gzip on;
+ gzip_types text/css application/javascript;
+ gzip_min_length 500;
+ gzip_disable msie6;
+ }
+
+ # patchwork web UI and API
+ location / {
+ include uwsgi_params;
+ uwsgi_pass unix:/run/patchwork/uwsgi.sock;
+ }
+}
diff --git a/parsemail.sh b/parsemail.sh
new file mode 100755
index 000000000000..a597b4ec1e05
--- /dev/null
+++ b/parsemail.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+
+# Called by postfix pipe transport to feed incoming mail to patchwork.
+# Exit 75 (EX_TEMPFAIL) on failure so postfix retries later.
+
+set -a
+. /etc/default/patchwork
+set +a
+
+cd $(dirname $0)
+systemd-cat --identifier=parsemail --priority=debug --stderr-priority=err \
+ python3 manage.py parsemail || exit 75
diff --git a/patchwork.service b/patchwork.service
new file mode 100644
index 000000000000..3b8c3102473b
--- /dev/null
+++ b/patchwork.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=Patchwork uWSGI
+After=postgresql.service
+
+[Service]
+ExecStart=/usr/bin/uwsgi --ini /etc/uwsgi/apps-enabled/patchwork.ini
+Restart=always
+KillSignal=SIGQUIT
+Type=notify
+NotifyAccess=all
+User=www-data
+Group=www-data
+EnvironmentFile=/etc/default/patchwork
+ReadWritePaths=/var/cache/patchwork/mirrors
+RuntimeDirectory=patchwork
+ProtectSystem=strict
+ProtectHome=true
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/patchwork/settings/production.py b/patchwork/settings/production.py
new file mode 100644
index 000000000000..1c3b5d00b1fe
--- /dev/null
+++ b/patchwork/settings/production.py
@@ -0,0 +1,68 @@
+"""
+Patchwork production settings for forge sync deployment.
+"""
+
+import logging.handlers
+import os
+
+from .base import * # noqa
+
+DEFAULT_FROM_EMAIL = os.environ.get("FROM_EMAIL", DEFAULT_FROM_EMAIL)
+SERVER_EMAIL = DEFAULT_FROM_EMAIL
+NOTIFICATION_FROM_EMAIL = DEFAULT_FROM_EMAIL
+EMAIL_SUBJECT_PREFIX = "[patchwork] "
+
+SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
+
+ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost").split(",")
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql_psycopg2",
+ "HOST": os.environ.get("DATABASE_HOST", "localhost"),
+ "PORT": os.environ.get("DATABASE_PORT", ""),
+ "NAME": os.environ.get("DATABASE_NAME", "patchwork"),
+ "USER": os.environ.get("DATABASE_USER", "patchwork"),
+ "PASSWORD": os.environ.get("DATABASE_PASSWORD", ""),
+ },
+}
+
+STATIC_ROOT = os.environ.get("STATIC_ROOT", "/var/www/patchwork")
+
+STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
+
+TIME_ZONE = "UTC"
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "syslog": {
+ "format": "%(message)s",
+ },
+ },
+ "handlers": {
+ "syslog": {
+ "class": "logging.handlers.SysLogHandler",
+ "address": "/dev/log",
+ "facility": logging.handlers.SysLogHandler.LOG_LOCAL0,
+ "formatter": "syslog",
+ },
+ },
+ "root": {
+ "handlers": ["syslog"],
+ "level": "INFO",
+ },
+}
+
+ENABLE_FORGE = True
+FORGE_BACKENDS = ["patchwork.forge.github"]
+FORGE_WEBHOOK_SECRETS = {
+ "github": os.environ.get("GH_WEBHOOK_SECRET", ""),
+}
+FORGE_GIT_MIRROR_PATH = "/var/cache/patchwork/mirrors"
+FORGE_AUTH = {
+ "github": {
+ "token": os.environ.get("GH_TOKEN", ""),
+ },
+}
diff --git a/postfix/client_access b/postfix/client_access
new file mode 100644
index 000000000000..91907452942b
--- /dev/null
+++ b/postfix/client_access
@@ -0,0 +1,2 @@
+# mails.dpdk.org
+217.70.189.124/32 OK
diff --git a/postfix/header_checks b/postfix/header_checks
new file mode 100644
index 000000000000..751678685afb
--- /dev/null
+++ b/postfix/header_checks
@@ -0,0 +1,2 @@
+/^From:.*pw@patches\.jarry\.cc/ PREPEND List-Id: <pwforge.jarry.cc>
+/^To:.*pw@patches\.jarry\.cc/ PREPEND List-Id: <pwforge.jarry.cc>
diff --git a/postfix/main.cf b/postfix/main.cf
new file mode 100644
index 000000000000..d5160981bdaa
--- /dev/null
+++ b/postfix/main.cf
@@ -0,0 +1,53 @@
+# Postfix configuration for pwforge patchwork mail ingestion.
+#
+# This instance only accepts mail from a designated upstream MTA and
+# delivers it to patchwork via a pipe transport. It does not relay
+# or deliver mail to local users.
+
+compatibility_level = 3.6
+
+# Replace with your actual hostname and domain.
+myhostname = patches.jarry.cc
+mydomain = patches.jarry.cc
+myorigin = $mydomain
+mydestination = $myhostname, localhost
+
+inet_interfaces = all
+inet_protocols = all
+
+# Only accept mail from the upstream MTA and localhost (for pwforge's
+# git send-email). Replace CHANGE_ME with the upstream MTA address.
+mynetworks = 127.0.0.0/8, [::1]/128
+smtpd_client_restrictions =
+ permit_mynetworks,
+ check_client_access cidr:/etc/postfix/client_access,
+ reject
+
+# Route list mail to patchwork parsemail pipe.
+header_checks = regexp:/etc/postfix/header_checks
+recipient_bcc_maps = hash:/etc/postfix/recipient_bcc
+transport_maps = hash:/etc/postfix/transport
+
+# Discard mail to unknown local users silently.
+local_recipient_maps =
+fallback_transport = discard:
+
+# Do not relay email, only allow local send
+smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
+default_transport = smtp:
+relay_domains =
+relayhost =
+
+# Generous limits for patch series.
+message_size_limit = 52428800
+mailbox_size_limit = 0
+
+# Retry quickly on transient failures (e.g. database locks).
+minimal_backoff_time = 30s
+maximal_backoff_time = 300s
+queue_run_delay = 30s
+
+# DKIM
+milter_default_action = accept
+smtpd_milters = unix:/run/opendkim/opendkim.sock
+non_smtpd_milters = unix:/run/opendkim/opendkim.sock
diff --git a/postfix/master.cf b/postfix/master.cf
new file mode 100644
index 000000000000..54e8e0b248ab
--- /dev/null
+++ b/postfix/master.cf
@@ -0,0 +1,28 @@
+# Postfix master process configuration for pwforge patchwork ingestion.
+
+smtp inet n - n - - smtpd
+pickup unix n - n 60 1 pickup
+cleanup unix n - n - 0 cleanup
+qmgr unix n - n 300 1 qmgr
+tlsmgr unix - - n 1000? 1 tlsmgr
+rewrite unix - - n - - trivial-rewrite
+bounce unix - - n - 0 bounce
+defer unix - - n - 0 bounce
+trace unix - - n - 0 bounce
+verify unix - - n - 1 verify
+flush unix n - n 1000? 0 flush
+proxymap unix - - n - - proxymap
+proxywrite unix - - n - 1 proxymap
+smtp unix - - n - - smtp
+relay unix - - n - - smtp -o syslog_name=postfix/$service_name
+showq unix n - n - - showq
+error unix - - n - - error
+retry unix - - n - - error
+discard unix - - n - - discard
+local unix - n n - - local
+virtual unix - n n - - virtual
+lmtp unix - - n - - lmtp
+anvil unix - - n - 1 anvil
+scache unix - - n - 1 scache
+postlog unix-dgram n - n - 1 postlogd
+patchwork unix - n n - 1 pipe flags=FR user=www-data argv=/opt/patchwork/parsemail.sh
diff --git a/postfix/recipient_bcc b/postfix/recipient_bcc
new file mode 100644
index 000000000000..7c6c2230904c
--- /dev/null
+++ b/postfix/recipient_bcc
@@ -0,0 +1 @@
+pw@patches.jarry.cc robin@jarry.cc
diff --git a/postfix/transport b/postfix/transport
new file mode 100644
index 000000000000..742b403310c3
--- /dev/null
+++ b/postfix/transport
@@ -0,0 +1,2 @@
+# Route mail for the subscribed address to patchwork
+patches.jarry.cc patchwork:
diff --git a/uwsgi.ini b/uwsgi.ini
new file mode 100644
index 000000000000..12811e2c63d9
--- /dev/null
+++ b/uwsgi.ini
@@ -0,0 +1,10 @@
+[uwsgi]
+plugins = python3
+chdir = /opt/patchwork
+module = patchwork.wsgi:application
+master = true
+processes = 4
+socket = /run/patchwork/uwsgi.sock
+chmod-socket = 660
+buffer-size = 16384
+vacuum = true