Installation
This document describes how to deploy a production Patchwork instance. Patchwork 4.0 is a single Go binary that replaces the previous Django/Python deployment.
Requirements
Patchwork requires:
The
pwbinary (built from source or downloaded from a release)A supported database: PostgreSQL (recommended), MySQL/MariaDB, or SQLite
A mail transfer agent (e.g. Postfix) for receiving patches
A reverse proxy (e.g. nginx) for TLS termination (optional but recommended)
Building from source requires Go 1.22 or later:
$ git clone https://github.com/getpatchwork/patchwork.git
$ cd patchwork
$ make
$ sudo make install
Database Setup
PostgreSQL (Recommended)
Install PostgreSQL and create a database:
$ sudo apt-get install -y postgresql
$ sudo -u postgres createuser -d patchwork
$ sudo -u postgres createdb -O patchwork patchwork
SQLite
No setup is needed. Simply point the configuration at a file path:
[database]
url = "sqlite:///var/lib/patchwork/patchwork.db"
Note
SQLite is suitable for small installations and development. For production use with multiple concurrent users, PostgreSQL is recommended.
Configuration
Generate a default configuration file:
$ pw config > /etc/patchwork.toml
Edit /etc/patchwork.toml to set at least the database URL and the HTTP base
URL. See Configuration for a full reference of all available settings.
A minimal configuration looks like:
[database]
url = "postgres://patchwork:secret@localhost/patchwork"
auto-sync = true
[http]
listen = "127.0.0.1:8080"
base-url = "https://patchwork.example.com"
[ingress]
listen = "127.0.0.1:2525"
[smtp]
host = "localhost"
port = 25
from = "patchwork@example.com"
Initialize the Database
Create the database schema and seed default data:
$ pw db sync
Create an Admin User
Create a user account with admin privileges:
$ pw admin user create --admin -u admin -e admin@example.com
The command will prompt for a password interactively.
Create a Project
Create your first project:
$ pw admin project create \
-n "My Project" \
-l my-project \
-i my-project.example.com \
-e patches@example.com
Running Services
Patchwork consists of two long-running services:
pw httpThe HTTP server exposing the web interface and REST API.
pw ingressThe SMTP daemon that receives emails from your mail transfer agent.
systemd
The make install target installs systemd unit files. Enable and start the
services:
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now pw-http pw-ingress
The unit files are installed to /usr/lib/systemd/system. To override settings,
use systemctl edit:
$ sudo systemctl edit pw-http
Note
Both services read configuration from /etc/patchwork.toml by default.
Use the env:PATCHWORK_TOML environment variable to specify an alternative
path.
Reverse Proxy
A reverse proxy like nginx is recommended for TLS termination. Here is a complete nginx setup that deals with:
TLS enforcement
Certbot/Let’s Encrypt renewal
Rate limiting
“Honest” LLM crawler filtering
Optional JS challenge to block LLM crawlers that mask their identity
# Rate limiting zone: allow 20 requests/second per client.
limit_req_zone $pw_limit zone=pw:10m rate=20r/s;
# Enable JS challenge on all pages except API endpoints (which are typically
# accessed by non-browser clients). The /api/docs page is browser-facing so it
# gets the challenge too.
map $uri $pw_js_challenge {
default 1;
~^/api 0;
/api/docs 1;
}
# Block known LLM crawlers by User-Agent. These bots scrape content for AI
# training data. Honest ones identify themselves; add or remove entries as the
# landscape changes.
map $http_user_agent $pw_bad_bot {
default 0;
~*GPTBot 1;
~*OAI-SearchBot 1;
~*ChatGPT-User 1;
~*ClaudeBot 1;
~*Claude-Web 1;
~*anthropic-ai 1;
~*PerplexityBot 1;
~*CCBot 1;
~*Google-Extended 1;
~*Meta-ExternalAgent 1;
~*FacebookBot 1;
}
server {
listen 80;
listen [::]:80;
server_name patchwork.example.com;
server_tokens off;
limit_req zone=pw burst=10 nodelay;
# Certbot/Let's Encrypt HTTP-01 challenge handler.
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://patchwork.example.com$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name patchwork.example.com;
ssl_certificate /etc/letsencrypt/live/patchwork.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/patchwork.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
limit_req zone=pw burst=10 nodelay;
server_tokens off;
# Reject requests from known LLM crawlers that honestly identify
# themselves via their User-Agent header.
if ($pw_bad_bot) {
return 403;
}
# For all other clients, use a JS challenge to catch dishonest bots
# that do not declare themselves. Non-browser clients that cannot
# execute JavaScript will fail the challenge.
set $js_challenge_enabled $pw_js_challenge;
access_by_lua_file /etc/nginx/lua/js_challenge.lua;
location = favicon.ico {
access_log off;
log_not_found off;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The JS challenge requires the lua-nginx-module (packaged as
libnginx-mod-http-lua or nginx-mod-http-lua in most Linux distros).
Save the following to /etc/nginx/lua/js_challenge.lua:
-- JS challenge for nginx access phase.
--
-- Opt-in: only active when $js_challenge_enabled is set to "1".
-- On first request (no valid cookie), serves a page with inline JS that
-- sets an HMAC-signed cookie and reloads. The cookie is bound to the
-- client IP and expires after TTL seconds.
--
-- Cookie format: base64(timestamp:base64(HMAC-SHA1(ip:timestamp, secret)))
local TTL = 86400
local secret
-- Read the HMAC secret from disk, cached after first call.
local function get_secret()
if secret then return secret end
local f, err = io.open("/etc/nginx/lua/.secret", "r")
if not f then
ngx.log(ngx.ERR, "js_challenge: ", err)
return nil
end
secret = f:read("*l")
f:close()
if not secret or secret == "" then
ngx.log(ngx.ERR, "js_challenge: empty secret")
return nil
end
return secret
end
-- HMAC-SHA1 of client IP and timestamp.
local function sign(s, ts)
return ngx.encode_base64(ngx.hmac_sha1(s, ngx.var.remote_addr .. ":" .. ts))
end
-- Check whether the _js_ok cookie is present, not expired, and valid.
local function validate(s)
local raw = ngx.decode_base64(ngx.var.cookie__js_ok or "")
if not raw then return false end
local ts, mac = raw:match("^(%d+):(.+)$")
if not ts then return false end
if ngx.time() - tonumber(ts) > TTL then return false end
return mac == sign(s, ts)
end
-- Serve a challenge page. The cookie value is obfuscated as a char code
-- array so that a non-JS HTTP client cannot extract it.
local function challenge(s)
local ts = tostring(ngx.time())
local val = ngx.encode_base64(ts .. ":" .. sign(s, ts))
local codes = {}
for i = 1, #val do codes[i] = string.byte(val, i) end
ngx.header.content_type = "text/html; charset=utf-8"
ngx.header.cache_control = "no-store"
ngx.say(string.format( [=[<!doctype html>
<html><head><title>Verifying</title></head><body>
<noscript><p>Enable JavaScript to access this site.</p></noscript><script>
(function(){
var a = [%s], s = "";
for(var i = 0; i < a.length; i++)
s += String.fromCharCode(a[i]);
document.cookie = "_js_ok=" + s + ";path=/;max-age=%d;SameSite=Lax;Secure";
location.reload();
})()
</script></body></html>]=], table.concat(codes, ","), TTL))
ngx.exit(200)
end
local e = ngx.var.js_challenge_enabled
if not e or e == "" or e == "0" then return end
local s = get_secret()
if s and not validate(s) then challenge(s) end
Finally, generate an HMAC secret for the Lua script:
head -c 32 /dev/urandom | base64 > /etc/nginx/lua/.secret
user=$(nginx -T 2>/dev/null | sed -En 's/user (.+);/\1/p')
chmod 640 /etc/nginx/lua/.secret
chgrp $(id -gn $user) /etc/nginx/lua/.secret
Incoming Email
Patchwork needs to receive emails from your mailing list. The recommended
approach is to configure your mail transfer agent to forward messages to the
pw ingress SMTP daemon.
Postfix with Transport Maps
This is the recommended setup. Configure Postfix to route mail for your list
domain to the pw ingress daemon using transport maps.
Add to /etc/postfix/main.cf:
transport_maps = lmdb:/etc/postfix/transport
Create /etc/postfix/transport:
lists.example.com smtp:127.0.0.1:2525
Build the transport map and reload Postfix:
$ sudo postmap /etc/postfix/transport
$ sudo systemctl reload postfix
All mail addressed to lists.example.com will now be forwarded to pw ingress
over SMTP. No shell scripts, no special user accounts, no database grants.
Note
The pw ingress daemon matches incoming emails to projects by their
List-ID header. Make sure the -e (list email) and -i (list ID)
values of your project match what your mailing list software produces.
IMAP/POP3
For simpler setups, you can use a mail retriever like getmail to download messages from an inbox and pipe them to Patchwork:
[destination]
type = MDA_external
path = /usr/bin/pw
arguments = ("ingress", "--stdin",)
Manual Import
For one-off imports, pw ingress can read from stdin:
$ pw ingress --stdin < email.eml
$ pw ingress --mbox < archive.mbox
The --list-id flag can be used to override the List-ID header.
(Optional) VCS Integration
Patchwork can update patch states automatically when commits are pushed.
A post-receive Git hook can be configured to mark patches as “accepted” when
their corresponding commits land in the repository.
Refer to the post-receive.hook script in the Patchwork source tree for an
example implementation that uses the REST API.
Periodic Cleanup
Run garbage collection periodically to clean up expired sessions, stale email confirmations, and inactive users:
$ pw admin gc
A cron job or systemd timer is recommended (e.g.
/etc/cron.daily/patchwork-gc):
#!/bin/sh
exec /usr/bin/pw admin gc