Merge pull request 'feature/postfix-pgsql-image' (#8) from feature/postfix-pgsql-image into main
All checks were successful
Build and Push Docker Images / build (push) Successful in 40s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-06-26 09:28:15 +00:00
9 changed files with 474 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
# =============================================================================
# Postfix + PostgreSQL Relay/MTA Image
#
# A pure SMTP relay/MTA: receives inbound mail and forwards it based on
# PostgreSQL-backed relay_domains/transport_maps lookups, equivalent to the
# Postfix setup described in the SimpleLogin self-hosting documentation
# (https://github.com/simple-login/app). No domain, hostname, or database
# credentials are baked into the image; everything is templated from
# environment variables at container startup. This image has no knowledge
# of what consumes the relayed mail.
# =============================================================================
# POSTFIX_VERSION pins the Debian base image tag (e.g. "12-slim"), which in
# turn pins the postfix/postfix-pgsql package versions available via apt.
ARG POSTFIX_VERSION=12-slim
FROM debian:${POSTFIX_VERSION}
LABEL maintainer="Damien Arnodo"
LABEL description="Postfix SMTP relay/MTA with PostgreSQL-backed relay_domains and transport_maps lookups"
RUN echo "postfix postfix/main_mailer_type select No configuration" | debconf-set-selections \
&& echo "postfix postfix/mailname string localhost" | debconf-set-selections \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
postfix \
postfix-pgsql \
gettext-base \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY templates/ /etc/postfix/templates/
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 25
ENTRYPOINT ["docker-entrypoint.sh"]

View File

@@ -0,0 +1 @@
12-slim

View File

@@ -0,0 +1,136 @@
# Postfix PostgreSQL Relay Image
A Postfix SMTP relay/MTA whose relay and transport decisions are driven by
PostgreSQL lookup maps instead of static config. It receives inbound mail and
forwards it to an internal SMTP target after checking the recipient's domain
against a database — it has no knowledge of, and makes no assumptions about,
what consumes the mail downstream.
This mirrors the Postfix setup described in the SimpleLogin self-hosting
documentation (`postfix` + `postfix-pgsql`, a `relay_domains` PostgreSQL map,
and a `transport_maps` PostgreSQL map): https://github.com/simple-login/app
No domain, hostname, or database credential is hardcoded in the image.
Everything is templated from environment variables by
`docker-entrypoint.sh` at container startup, then Postfix is started in the
foreground (`postfix start-fg`) so it runs correctly under Docker's process
supervision.
## How it works
On startup, the entrypoint renders three files from templates under
`/etc/postfix/templates/`:
- `/etc/postfix/main.cf` — core Postfix configuration.
- `/etc/postfix/pgsql-relay-domains.cf` — PostgreSQL lookup that tells
Postfix which domains it should relay mail for.
- `/etc/postfix/pgsql-transport-maps.cf` — PostgreSQL lookup that tells
Postfix where to forward accepted mail (the internal SMTP relay target).
Chroot is disabled for all Postfix services via `postconf -F '*/*/chroot = n'`
on every startup. Postfix's chrooted child processes (smtpd, trivial-rewrite,
etc.) lack access to `/etc/resolv.conf` inside the chroot jail, so they cannot
resolve Docker-internal hostnames — causing every PostgreSQL lookup to fail with
`451 4.3.0 Temporary lookup failure`. Disabling chroot is the standard remedy
in containerised deployments.
## Environment variables
### Required
| Variable | Description | Example |
|----------|--------------|---------|
| `MAIL_DOMAIN` | Mail domain this relay handles (`mydomain`/`myorigin` in `main.cf`). | `example.com` |
| `MYHOSTNAME` | Hostname of this relay, used as Postfix's `myhostname` and as the MX target for `MAIL_DOMAIN`. | `mx1.example.com` |
| `POSTGRES_HOST` | Hostname of the PostgreSQL server holding the lookup tables. | `postgres` |
| `POSTGRES_PORT` | PostgreSQL port. | `5432` |
| `POSTGRES_USER` | PostgreSQL user used for the lookup queries. | `postfix` |
| `POSTGRES_PASSWORD` | PostgreSQL password for `POSTGRES_USER`. | `change-me` |
| `POSTGRES_DB` | PostgreSQL database name. | `relaydb` |
| `SMTP_RELAY_HOST` | Internal SMTP host that accepted mail is forwarded to after the relay-domains lookup matches. | `app-internal` |
| `SMTP_RELAY_PORT` | Port of the internal SMTP relay target. | `2525` |
### Optional
| Variable | Description |
|----------|--------------|
| `TLS_CERT_FILE` | Absolute path (inside the container) to a TLS certificate to use for `smtpd_tls_cert_file`. If unset, a self-signed certificate is generated on first start. |
| `TLS_KEY_FILE` | Absolute path (inside the container) to the private key matching `TLS_CERT_FILE`. Must be set together with `TLS_CERT_FILE`. |
The PostgreSQL lookup tables are expected to expose at least a
`relay_domain(domain)` table; both lookups query it by recipient domain.
## TLS certificates
By default, if `TLS_CERT_FILE`/`TLS_KEY_FILE` are not provided, the
entrypoint generates a self-signed certificate at
`/etc/postfix/ssl/snakeoil.{pem,key}` on first start (matching the
self-signed fallback used in the upstream docs) and reuses it on subsequent
restarts if that path is kept on a persistent volume.
To use your own certificate instead, mount it into the container and point
`TLS_CERT_FILE`/`TLS_KEY_FILE` at the mounted paths, e.g.:
```bash
docker run \
-v /path/to/fullchain.pem:/certs/fullchain.pem:ro \
-v /path/to/privkey.pem:/certs/privkey.pem:ro \
-e TLS_CERT_FILE=/certs/fullchain.pem \
-e TLS_KEY_FILE=/certs/privkey.pem \
...
```
## Usage
### `docker run`
```bash
docker run -d \
-p 25:25 \
-e MAIL_DOMAIN=example.com \
-e MYHOSTNAME=mx1.example.com \
-e POSTGRES_HOST=postgres \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=postfix \
-e POSTGRES_PASSWORD=change-me \
-e POSTGRES_DB=relaydb \
-e SMTP_RELAY_HOST=app-internal \
-e SMTP_RELAY_PORT=2525 \
gitea.arnodo.fr/damien/postfix-pgsql:latest
```
### Docker Compose
```yaml
services:
postfix:
image: gitea.arnodo.fr/damien/postfix-pgsql:latest
ports:
- "25:25"
environment:
MAIL_DOMAIN: example.com
MYHOSTNAME: mx1.example.com
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_USER: postfix
POSTGRES_PASSWORD: change-me
POSTGRES_DB: relaydb
SMTP_RELAY_HOST: app-internal
SMTP_RELAY_PORT: "2525"
```
## Version management
The Debian base image tag is set in `POSTFIX_VERSION` (e.g. `12-slim`),
which also pins the `postfix`/`postfix-pgsql` package versions available via
`apt` for that Debian release. To change it:
1. Edit `POSTFIX_VERSION` with the desired Debian tag.
2. Commit and push.
3. The CI builds the image with that base and tags it with both `latest` and
the value of `POSTFIX_VERSION`.
## Reference
- Upstream Postfix + PostgreSQL setup this image is based on:
https://github.com/simple-login/app

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# =============================================================================
# Renders Postfix config templates from environment variables and starts
# Postfix in the foreground.
# =============================================================================
set -euo pipefail
TEMPLATE_DIR="/etc/postfix/templates"
SSL_DIR="/etc/postfix/ssl"
require_var() {
local name="$1"
if [ -z "${!name:-}" ]; then
echo "ERROR: required environment variable ${name} is not set" >&2
exit 1
fi
}
for var in MAIL_DOMAIN MYHOSTNAME \
POSTGRES_HOST POSTGRES_PORT POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB \
SMTP_RELAY_HOST SMTP_RELAY_PORT; do
require_var "$var"
done
# TLS: use mounted cert/key if provided, otherwise generate a self-signed
# "snakeoil" certificate on first start, matching the upstream docs' approach.
mkdir -p "$SSL_DIR"
if [ -n "${TLS_CERT_FILE:-}" ] && [ -n "${TLS_KEY_FILE:-}" ]; then
export SMTPD_TLS_CERT_FILE="$TLS_CERT_FILE"
export SMTPD_TLS_KEY_FILE="$TLS_KEY_FILE"
else
export SMTPD_TLS_CERT_FILE="${SSL_DIR}/snakeoil.pem"
export SMTPD_TLS_KEY_FILE="${SSL_DIR}/snakeoil.key"
if [ ! -f "$SMTPD_TLS_CERT_FILE" ] || [ ! -f "$SMTPD_TLS_KEY_FILE" ]; then
echo "No TLS_CERT_FILE/TLS_KEY_FILE provided, generating a self-signed certificate"
openssl req -x509 -nodes -days 3650 \
-newkey rsa:2048 \
-subj "/CN=${MYHOSTNAME}" \
-keyout "$SMTPD_TLS_KEY_FILE" \
-out "$SMTPD_TLS_CERT_FILE"
chmod 600 "$SMTPD_TLS_KEY_FILE"
fi
fi
render() {
local template="$1"
local destination="$2"
envsubst '${MAIL_DOMAIN} ${MYHOSTNAME} ${SMTPD_TLS_CERT_FILE} ${SMTPD_TLS_KEY_FILE} ${POSTGRES_HOST} ${POSTGRES_PORT} ${POSTGRES_USER} ${POSTGRES_PASSWORD} ${POSTGRES_DB} ${SMTP_RELAY_HOST} ${SMTP_RELAY_PORT}' \
< "$template" > "$destination"
}
render "${TEMPLATE_DIR}/main.cf.template" /etc/postfix/main.cf
render "${TEMPLATE_DIR}/pgsql-relay-domains.cf.template" /etc/postfix/pgsql-relay-domains.cf
render "${TEMPLATE_DIR}/pgsql-transport-maps.cf.template" /etc/postfix/pgsql-transport-maps.cf
# The pgsql lookup config files embed the database password; restrict access.
chown root:postfix /etc/postfix/pgsql-relay-domains.cf /etc/postfix/pgsql-transport-maps.cf
chmod 640 /etc/postfix/pgsql-relay-domains.cf /etc/postfix/pgsql-transport-maps.cf
postfix check
# Disable chroot for all master.cf services so child processes (smtpd,
# trivial-rewrite, etc.) can resolve Docker-internal hostnames via the host's
# /etc/resolv.conf. Without this, chrooted processes have no DNS and every
# pgsql lookup returns "451 4.3.0 Temporary lookup failure".
postconf -F '*/*/chroot = n'
exec /usr/sbin/postfix start-fg

View File

@@ -0,0 +1,39 @@
# =============================================================================
# Postfix main.cf template
#
# Rendered at container startup by docker-entrypoint.sh, with values
# substituted from environment variables. See README.md for the full list
# of supported variables.
# =============================================================================
myhostname = ${MYHOSTNAME}
mydomain = ${MAIL_DOMAIN}
myorigin = $mydomain
mydestination =
inet_interfaces = all
inet_protocols = ipv4
# Dynamic relay/transport decisions backed by PostgreSQL lookup maps.
relay_domains = pgsql:/etc/postfix/pgsql-relay-domains.cf
transport_maps = pgsql:/etc/postfix/pgsql-transport-maps.cf
# This instance only relays mail for the domains returned by relay_domains;
# it does not accept mail for arbitrary destinations.
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
mynetworks = 127.0.0.0/8, [::1]/128
# TLS
smtpd_tls_cert_file = ${SMTPD_TLS_CERT_FILE}
smtpd_tls_key_file = ${SMTPD_TLS_KEY_FILE}
smtpd_use_tls = yes
smtpd_tls_security_level = may
smtp_tls_security_level = may
# Log to stdout/stderr so `docker logs` captures Postfix activity directly,
# without needing a syslog daemon inside the container.
maillog_file = /dev/stdout
biff = no
append_dot_mydomain = no
readme_directory = no
compatibility_level = 3.6

View File

@@ -0,0 +1,12 @@
# =============================================================================
# Postfix pgsql_table lookup: relay_domains
#
# Returns the domain itself when Postfix should relay mail for it.
# Rendered at container startup from environment variables.
# =============================================================================
user = ${POSTGRES_USER}
password = ${POSTGRES_PASSWORD}
hosts = ${POSTGRES_HOST}:${POSTGRES_PORT}
dbname = ${POSTGRES_DB}
query = SELECT domain FROM relay_domain WHERE domain='%s'

View File

@@ -0,0 +1,15 @@
# =============================================================================
# Postfix pgsql_table lookup: transport_maps
#
# Returns the internal SMTP relay target that accepted mail gets forwarded
# to once a recipient domain matches relay_domains. The target itself is
# baked into the query from SMTP_RELAY_HOST/SMTP_RELAY_PORT at startup; this
# image has no further knowledge of what consumes the mail downstream.
# Rendered at container startup from environment variables.
# =============================================================================
user = ${POSTGRES_USER}
password = ${POSTGRES_PASSWORD}
hosts = ${POSTGRES_HOST}:${POSTGRES_PORT}
dbname = ${POSTGRES_DB}
query = SELECT 'smtp:[${SMTP_RELAY_HOST}]:${SMTP_RELAY_PORT}' FROM relay_domain WHERE domain='%s'

View File

@@ -0,0 +1,142 @@
# Local Test Environment Setup
Step-by-step instructions for standing up a self-contained test environment for
the `postfix-pgsql` image on any Linux host with Docker installed. No cloud
access is required.
## Architecture
```
External SMTP client (e.g. swaks / telnet)
|
| :25
v
[ postfix-pgsql ] ──pgsql lookup──> [ postgres ]
|
| :2525 (SMTP_RELAY_PORT)
v
[ mailhog ] <── captured mail browsable at :8025
```
MailHog acts as a stand-in for the downstream SMTP consumer and captures every
message delivered to it, making it easy to verify end-to-end relay without
actually sending real mail.
## 1. Create a dedicated Docker network
All containers must share the same network so they can resolve each other by
container name (required because chroot is disabled in this image — see the
README).
```bash
docker network create postfix-test-net
```
## 2. Start PostgreSQL with the relay_domain schema
```bash
docker run -d \
--name test-postgres \
--network postfix-test-net \
-e POSTGRES_USER=relayuser \
-e POSTGRES_PASSWORD=testpassword \
-e POSTGRES_DB=relaydb \
postgres:16-alpine
```
Wait for Postgres to be ready, then create the `relay_domain` table and insert
a test domain:
```bash
docker exec -i test-postgres psql -U relayuser -d relaydb <<'SQL'
CREATE TABLE relay_domain (domain TEXT PRIMARY KEY);
INSERT INTO relay_domain VALUES ('example.com');
SQL
```
Verify:
```bash
docker exec -i test-postgres psql -U relayuser -d relaydb -c "SELECT * FROM relay_domain;"
```
## 3. Start MailHog
MailHog listens on port 2525 for SMTP (matching `SMTP_RELAY_PORT`) and exposes
a web UI on port 8025.
```bash
docker run -d \
--name test-mailhog \
--network postfix-test-net \
-p 8025:8025 \
mailhog/mailhog \
MailHog -smtp-bind-addr 0.0.0.0:2525
```
## 4. Start the postfix-pgsql container
```bash
docker run -d \
--name test-postfix \
--network postfix-test-net \
-p 25:25 \
-e MAIL_DOMAIN=example.com \
-e MYHOSTNAME=mx1.example.com \
-e POSTGRES_HOST=test-postgres \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=relayuser \
-e POSTGRES_PASSWORD=testpassword \
-e POSTGRES_DB=relaydb \
-e SMTP_RELAY_HOST=test-mailhog \
-e SMTP_RELAY_PORT=2525 \
gitea.arnodo.fr/damien/postfix-pgsql:latest
```
Confirm it started cleanly:
```bash
docker logs test-postfix
```
Expected: Postfix master process line and no errors.
## 5. Send a test message
You can use `swaks` (Swiss Army Knife for SMTP) or plain `telnet`:
```bash
# With swaks (install: apt install swaks / brew install swaks)
swaks --to user@example.com \
--from sender@external.com \
--server 127.0.0.1 \
--port 25
# Or with telnet
telnet 127.0.0.1 25
EHLO test
MAIL FROM:<sender@external.com>
RCPT TO:<user@example.com>
DATA
Subject: Test
.
QUIT
```
## 6. View captured mail in MailHog
**Web UI** — if testing on a remote host, open an SSH tunnel first:
```bash
ssh -L 8025:localhost:8025 user@your-host
```
Then open `http://localhost:8025` in a browser.
**HTTP API** (no tunnel needed):
```bash
curl -s http://localhost:8025/api/v2/messages | jq '.items[].Content.Headers.Subject'
```
## 7. Cleanup
```bash
docker rm -f test-postfix test-mailhog test-postgres
docker network rm postfix-test-net
```

View File

@@ -0,0 +1,23 @@
# Test Plan: postfix-pgsql image
Manual test suite run against the image during initial validation on a Scaleway
test VM. All tests use the local environment described in `setup.md`.
**Convention for this table:**
- *Command(s)* are abbreviated; see `setup.md` for full environment setup.
- Add new rows at the bottom as the image evolves; do not rewrite existing rows
(failed + fixed tests are part of the history).
- Status: ✅ Pass · ❌ Fail · 🔧 Fixed (failed, root-caused, fix applied)
| # | Test | What was checked | Command(s) | Expected | Actual | Status |
|---|------|-----------------|------------|----------|--------|--------|
| 1 | Clean startup | No errors in container logs after start; Postfix master process running | `docker run ... gitea.arnodo.fr/damien/postfix-pgsql:latest` then `docker logs test-postfix` | Postfix `daemon started` line, no `fatal`/`panic` entries | Logs show `postfix/master[1]: daemon started -- version 3.7.x`, self-signed cert generation output, no errors | ✅ Pass |
| 2 | Missing required env var | Container exits with a clear error message when a required variable is absent | `docker run ... -e POSTGRES_HOST= ...` (POSTGRES_HOST set to empty) | Non-zero exit, `ERROR: required environment variable POSTGRES_HOST is not set` printed to stderr | Container exited immediately with the expected message | ✅ Pass |
| 3 | Template rendering correctness | Rendered `main.cf` contains substituted values, no leftover `${VAR}` placeholders | `docker exec test-postfix cat /etc/postfix/main.cf` | `myhostname = mx1.example.com`, `mydomain = example.com`, TLS paths resolved, no literal `${...}` strings | All values substituted; Postfix-native `$mydomain` references (no braces) correctly left untouched by envsubst | ✅ Pass |
| 4 | Self-signed TLS cert auto-generated | When no TLS_CERT_FILE/TLS_KEY_FILE are provided, a self-signed cert is generated and persisted | Start container without TLS vars; `docker exec test-postfix ls -l /etc/postfix/ssl/` | Files `snakeoil.pem` and `snakeoil.key` present; key mode 600 | Both files created on first start; `postconf smtpd_tls_cert_file` returned the correct path | ✅ Pass |
| 5 | Port 25 reachable | SMTP port 25 is accessible from outside the container and returns a valid Postfix banner | `telnet 127.0.0.1 25` | `220 mx1.example.com ESMTP Postfix` banner | Banner received as expected | ✅ Pass |
| 6 | Accepted domain → mail relayed | Mail sent to a recipient whose domain is in `relay_domain` is forwarded to the downstream SMTP target (MailHog) | `swaks --to user@example.com --server 127.0.0.1 --port 25`; check MailHog API | `250 2.0.0 Ok: queued` from Postfix; message appears in `GET /api/v2/messages` | Message delivered to MailHog and visible in the web UI | ✅ Pass |
| 7 | Unknown domain → relay denied | Mail sent to a domain not in `relay_domain` is rejected with relay access denied | `swaks --to user@notlisted.org --server 127.0.0.1 --port 25` | `554 5.7.1 Relay access denied` | Postfix returned `554 5.7.1 <user@notlisted.org>: Relay access denied` | ✅ Pass |
| 8 | PostgreSQL lookup failure — chroot DNS bug | pgsql-backed `relay_domains`/`transport_maps` lookups work correctly when Postgres is referenced by Docker container name | Send mail to `user@example.com` with `POSTGRES_HOST=test-postgres` (container name, not IP) | `250 Ok: queued`; no `451 Temporary lookup failure` in logs | **Without fix:** intermittent `451 4.3.0 Temporary lookup failure` — chrooted child processes (smtpd, trivial-rewrite) could not resolve `test-postgres` because they lack `/etc/resolv.conf` inside the chroot jail. **Fix applied:** added `postconf -F '*/*/chroot = n'` in `docker-entrypoint.sh` before `postfix start-fg`. After fix: lookups succeed consistently. | 🔧 Fixed |
| 9 | Container restart behavior | Config is re-rendered correctly on restart; pre-existing self-signed cert is reused without re-generation | `docker restart test-postfix`; `docker logs test-postfix` | No re-generation message for TLS cert; `main.cf` and pgsql maps fully substituted; Postfix starts without errors | Cert reuse message absent (new cert would log openssl output); Postfix started cleanly on restart | ✅ Pass |
| 10 | No credential leakage in logs | Postgres password does not appear anywhere in `docker logs` output | `docker logs test-postfix 2>&1 \| grep testpassword` | No output (password not printed) | No lines containing the password; envsubst substitution into config files is silent | ✅ Pass |