Self-hosted webmentions
December 06, 2025 —
~wheresalice
The centralisation of webmentions on webmention.io is bad, partly caused by the lack of good options. There’s a couple of self-hosted servers available, including go-jamming and webmentiond but these require you to run your own server. So instead of figuring out how to make either of these work in docker-compose, I wrote my own. In Bash. As a CGI script.
#!/bin/bash
# setup:
# 1. chmod a+x this file
# 2. mkdir ~/webmentions && chmod a+w ~/webmentions
# 3. sqlite3 ~/webmentions/webmention.db "CREATE TABLE webmentions(published TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,source TEXT NOT NULL,target TEXT NOT NULL,UNIQUE(source, target));"
# 4. change DB_DIR to point to the full path to your webmentions directory
#
# test:
# curl -i -d source="https://www.example.com" -d target="https://wheresalice.info" https://wheresalice.envs.net/cgi-bin/webmention.sh
#
# references
# https://devtut.github.io/bash/cgi-scripts.html#request-method-get
# https://gist.github.com/dam2k/5df0d8d3fdabc41e8ce2c799734f65d4
set -euo pipefail
readonly DB_DIR="/home/wheresalice/webmentions"
: "${FILTER_PATTERN:=^[A-Za-z0-9_]+$}"
function urldecode() {
: "${*//+/ }"
echo -e "${_//%/\\x}"
}
filter() {
echo "$1" | tr -d \' | tr -d \` | tr -d \$ | tr -d \;
}
sendstatus() {
printf 'Content-Type: text/plain\n'
printf 'Status: %s\n' "$1"
printf '\n'
}
main() {
export REQUEST_METHOD=${REQUEST_METHOD:-}
export CONTENT_TYPE=${CONTENT_TYPE:-}
if [ "${REQUEST_METHOD}" != "POST" ]; then
sendstatus '400 Bad Request'
printf 'Only POST is supported'
exit 0
fi
if [ -z "${CONTENT_TYPE##*x-www-form-urlencoded*}" ]; then
true
else
sendstatus '400 Bad Request'
printf 'Unsupported Media Type\n'
printf 'Expected application/x-www-form-urlencoded'
exit 0
fi
read -r -n "$CONTENT_LENGTH" QUERY_STRING_POST
local source target
source=$(echo "${QUERY_STRING_POST}" | awk 'match($0, /source=([^&]+)/, a) { print a[1] }')
target=$(echo "${QUERY_STRING_POST}" | awk 'match($0, /target=([^&]+)/, a) { print a[1] }')
source=$(urldecode "${source}")
target=$(urldecode "${target}")
source=$(filter "${source}")
target=$(filter "${target}")
if [ -z "${source}" ] || [ -z "${target}" ]; then
sendstatus '400 Bad Request'
printf 'source and target are required\n'
printf "%s -> %s\n" "${source}" "${target}"
echo "${QUERY_STRING_POST}"
exit 0
fi
(printf "PRAGMA journal_mode = WAL; INSERT INTO webmentions (source,target) VALUES('%s','%s');" "${source}" "${target}") | sqlite3 "${DB_DIR}/webmention.db" >/dev/null
sendstatus '202 Accepted'
printf "%s -> %s\n" "${source}" "${target}"
}
main "$@"Tags: indieweb