updated app data storage and AIO docker setup

This commit is contained in:
2026-08-16 09:24:21 +01:00
parent 5dce9f4bcb
commit 71758786cb
17 changed files with 418 additions and 106 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
# Copy to .env (in this docker-deploy/ folder) and adjust if the defaults below clash
# with something else on the host, or if you want to run both the "standalone" and
# "with-rspamd" profiles side by side (give each its own set of host ports).
# with something else on the host, or if you want to run more than one of the
# "standalone" / "with-rspamd" / "all-in-one" profiles side by side (give each its own
# set of host ports).
SMTP_PORT=25
SMTP_TLS_PORT=465
IMAP_PORT=143
+79
View File
@@ -0,0 +1,79 @@
# syntax=docker/dockerfile:1
#
# All-in-one: mailgoserver + rspamd + redis, bundled in one container — the full spam-
# filtering stack, not just rspamd's SPF/DKIM/RBL/regexp scoring. Redis backs rspamd's
# Bayes classifier (learns from mail marked as spam/ham) and its greylisting module,
# neither of which work without it (see docker-deploy/README.md's original note on
# Dockerfile.rspamd, which intentionally skips redis for a lighter image — use that one
# instead if you don't want Bayes/greylisting). mailgoserver itself has no direct use
# for redis — it's a single-instance app already backed by SQLite for everything, so
# there's nothing here for redis to cache or coordinate; it exists purely to make
# rspamd's scoring meaningfully better.
#
# Build from the repo root:
# docker build -f docker-deploy/Dockerfile.aio -t mailgoserver-aio .
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mailgoserver .
FROM debian:bookworm-slim
# rspamd from its own APT repo (rspamd.com), same as Dockerfile.rspamd, for the latest
# stable release rather than Debian's older bundled version. redis-server is Debian's
# own package — no third-party repo needed there, and bookworm's version (7.0.x) is
# recent enough for everything rspamd's Bayes/greylist modules need.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata curl gnupg lsb-release libcap2-bin redis-server \
&& mkdir -p /usr/share/keyrings \
&& curl -fsSL https://rspamd.com/apt-stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/rspamd.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/rspamd.gpg] https://rspamd.com/apt-stable/ $(lsb_release -cs) main" \
> /etc/apt/sources.list.d/rspamd.list \
&& apt-get update && apt-get install -y --no-install-recommends rspamd \
&& rm -rf /var/lib/apt/lists/*
# Point rspamd's Bayes classifier and greylisting module at the redis instance this same
# container runs (loopback-only, see entrypoint-aio.sh) — both are otherwise inert
# without a redis backend. Every other redis-capable module (ratelimit, etc.) picks up
# the same servers = ... from this one redis.conf too, rspamd's own convention for
# sharing one connection config across modules. Also redirects rspamd's own dbdir (fuzzy
# storage, DNS/maps cache) into server_data/rspamd — created/chowned at container start
# by entrypoint-aio.sh, since it lives on the volume, not the image — so this whole
# bundle only ever needs one thing mounted: server_data/.
RUN mkdir -p /etc/rspamd/local.d \
&& printf 'servers = "127.0.0.1:6379";\n' > /etc/rspamd/local.d/redis.conf \
&& printf 'backend = "redis";\nservers = "127.0.0.1:6379";\n' > /etc/rspamd/local.d/classifier-bayes.conf \
&& printf 'enabled = true;\nservers = "127.0.0.1:6379";\n' > /etc/rspamd/local.d/greylist.conf \
&& printf 'dbdir = "/app/server_data/rspamd";\n' > /etc/rspamd/local.d/options.inc
COPY --from=build /out/mailgoserver /usr/local/bin/mailgoserver
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/mailgoserver
COPY docker-deploy/entrypoint-aio.sh /usr/local/bin/entrypoint-aio.sh
RUN chmod +x /usr/local/bin/entrypoint-aio.sh
# rspamd and redis's own .deb postinsts already create their system users;
# mailgoserver gets one here for the same reason as the other images — none of the
# three processes run as root (see entrypoint-aio.sh for how each is dropped).
RUN useradd --system --create-home --home-dir /app --shell /usr/sbin/nologin mailgoserver \
&& mkdir -p /app/server_data \
&& chown -R mailgoserver:mailgoserver /app \
&& chmod 755 /app
WORKDIR /app
VOLUME ["/app/server_data"]
# Deliberately stays root here (unlike the standalone Dockerfile's USER mailgoserver) —
# the entrypoint script itself needs root just long enough to chown server_data's
# rspamd/redis subdirectories and drop privileges for each child individually.
# 11334 (rspamd's own web UI, controller worker) is intentionally NOT exposed here —
# it has no authentication configured by default, and this bundle doesn't need it for
# anything mailgoserver itself uses. Add a `password` to rspamd's controller worker
# config and publish it yourself if you want it. Port 6379 (redis) is never exposed at
# all — loopback-only, see entrypoint-aio.sh.
EXPOSE 25 465 143 993 80 5000 5001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fs http://127.0.0.1:5000/health || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint-aio.sh"]
+29 -19
View File
@@ -59,21 +59,24 @@ the binary itself has no runtime dependencies (pure-Go SQLite driver, no cgo).
## What to expect on first run
There's no config to write up front. The first time it starts in a given working
directory, it generates:
directory, it generates everything under `server_data/` — one directory to back up or
volume-mount and every persistent thing this app owns is in it:
- `settings.ini` — every setting with an inline comment explaining it (SMTP/IMAP ports,
hostname, TLS, DKIM key size, mailbox quotas, MFA enforcement, rate limits, and so
on). Regenerated only if missing — it's never overwritten or merged into on later
runs, so edits stick.
- A self-signed TLS certificate (used until you either supply your own or enable Let's
Encrypt from the admin dashboard).
- An empty SQLite database, with one seeded admin account: username `admin`, password
`Password123!`. Logging in with it **immediately forces** a username + password
change before anything else in the dashboard is reachable — the default credentials
can never be left in place.
- A mailstore master key and a CSRF-signing app secret, both generated once and reused
on every subsequent start — back these up like any other secret (losing the mailstore
master key makes all stored mail unrecoverable, even for admins).
- `server_data/settings.ini` — every setting with an inline comment explaining it
(SMTP/IMAP ports, hostname, TLS, DKIM key size, mailbox quotas, MFA enforcement, rate
limits, and so on). Regenerated only if missing — it's never overwritten or merged
into on later runs, so edits stick. Override the path with `-config` if you want it
somewhere else.
- A self-signed TLS certificate under `server_data/ssl_certs/` (used until you either
supply your own or enable Let's Encrypt from the admin dashboard).
- `server_data/smtp_server.db` — an empty SQLite database, with one seeded admin
account: username `admin`, password `Password123!`. Logging in with it **immediately
forces** a username + password change before anything else in the dashboard is
reachable — the default credentials can never be left in place.
- A mailstore master key and a CSRF-signing app secret (also under `server_data/`),
both generated once and reused on every subsequent start — back these up like any
other secret (losing the mailstore master key makes all stored mail unrecoverable,
even for admins).
From there: log into `/smtp-server`, add a domain (and complete its DNS ownership
verification), add a mailbox or sender, and you're sending/receiving. The bare `/` root
@@ -136,13 +139,19 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/mailgoserver
# Everything this process ever writes lives under server_data/ (settings.ini, the DB,
# mailstore, TLS certs, keys) — narrower than the whole install directory.
ReadWritePaths=/opt/mailgoserver/server_data
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
(Widen `ReadWritePaths` if you've pointed `attachments_path`, `TLS_CERT_FILE`/
`TLS_KEY_FILE`, or `-config` itself at somewhere outside `server_data/` — everything
else this process writes stays inside it by default.)
If you do want the SMTP and web parts as separate services (matching the Python
split exactly), run two units with `--smtp-only` and `--web-only` respectively —
both flags exist for this.
@@ -152,13 +161,14 @@ both flags exist for this.
No changes needed. `script_nginx_setup.sh` reverse-proxies to `http://127.0.0.1:5000`
and terminates its own TLS for the web UI — mailgoserver listens on the same host:port
by default, so the existing nginx config works unmodified. The SMTP TLS listener still
consumes `ssl_certs/server.crt`/`server.key`, same as before.
consumes `server_data/ssl_certs/server.crt`/`server.key`, same as before.
## Docker
See [`docker-deploy/`](docker-deploy/) — a standalone image and one that bundles the
latest rspamd in the same container, both via a single `docker-compose.yml` using
Compose profiles.
See [`docker-deploy/`](docker-deploy/) — a standalone image, one that bundles the
latest rspamd in the same container, and an all-in-one image that adds redis too (for
rspamd's Bayes learning and greylisting), all via a single `docker-compose.yml` using
Compose profiles. None of the three run as root.
## Certificates
+33
View File
@@ -0,0 +1,33 @@
services:
mailserver-aio:
build:
context: ..
dockerfile: Dockerfile.aio
container_name: mailgoserver
hostname: mailgoserver
restart: unless-stopped
networks:
backend:
ports:
- "${SMTP_PORT:-25}:25"
- "${SMTP_TLS_PORT:-465}:465"
- "${IMAP_PORT:-143}:143"
- "${IMAP_TLS_PORT:-993}:993"
- "${ACME_HTTP_PORT:-80}:80"
- "${WEB_HTTP_PORT:-5000}:5000"
- "${WEB_HTTPS_PORT:-5001}:5001"
volumes:
- mailserver-aio-data:/app/server_data
volumes:
mailserver-aio-data:
driver: local
driver_opts:
o: bind
type: none
device: /opt/settings/mailgoserver/data
# This is external network what I use, you can use your own or remove it.
networks:
backend:
external: true
+25 -12
View File
@@ -3,7 +3,8 @@
# Standalone mailgoserver image — SMTP + IMAP + admin dashboard + webmail, no rspamd.
# The built-in heuristic spam score (internal/mailstore/spam.go) always runs regardless;
# this is for anyone who doesn't want rspamd's extra dependency. See Dockerfile.rspamd
# for the bundled variant.
# (mailgoserver + rspamd) and Dockerfile.aio (mailgoserver + rspamd + redis, full spam
# stack) for the bundled variants.
#
# Build from the repo root:
# docker build -f docker-deploy/Dockerfile -t mailgoserver .
@@ -22,24 +23,36 @@ FROM debian:bookworm-slim
# tzdata: [Server] time_zone (e.g. "Europe/London") needs the real IANA zone
# database — time.LoadLocation silently falls back to UTC without it.
# curl: used by the HEALTHCHECK below.
# libcap2-bin: provides setcap, used below so the binary can bind ports 25/465/80
# without running the process itself as root.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata curl \
ca-certificates tzdata curl libcap2-bin \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/mailgoserver /usr/local/bin/mailgoserver
# CAP_NET_BIND_SERVICE is one of Docker's default capabilities (no --cap-add needed at
# `docker run`/compose time) — granting it to the binary itself, rather than running the
# whole container as root, means a bug in mailgoserver can't do anything a root process
# could that an unprivileged one couldn't, beyond binding these specific low ports.
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/mailgoserver
# Everything persistent — the auto-generated settings.ini, the SQLite DB, encrypted
# mailbox storage, DKIM/mailstore master keys, TLS certs, the app secret — lives under
# whatever directory the process is started from (see config.Load / main.go's
# os.Getwd()). One volume here covers all of it; no need to enumerate subpaths.
WORKDIR /app/data
VOLUME ["/app/data"]
# Everything persistent — settings.ini, the SQLite DB, encrypted mailbox storage,
# DKIM/mailstore master keys, TLS certs, the app secret — lives under server_data/,
# relative to wherever the process is started from (see config.Load / main.go's
# os.Getwd()). One volume covers all of it; no need to enumerate subpaths, and nothing
# persistent is ever written outside it.
RUN useradd --system --create-home --home-dir /app --shell /usr/sbin/nologin mailgoserver \
&& mkdir -p /app/server_data \
&& chown -R mailgoserver:mailgoserver /app \
&& chmod 755 /app
WORKDIR /app
VOLUME ["/app/server_data"]
USER mailgoserver
# Defaults from internal/config/config.go's generated settings.ini: SMTP 25, direct-TLS
# SMTP 465, IMAP 143, direct-TLS IMAP 993 (the actual standard ports — binding them
# needs no setcap/capability here since this container runs as root), admin/webmail
# HTTP 5000, HTTPS 5001 (deliberately non-privileged; put a reverse proxy or the host's
# own 80/443 in front if you want those too). Port 80 is only actually bound while
# SMTP 465, IMAP 143, direct-TLS IMAP 993, admin/webmail HTTP 5000, HTTPS 5001
# (deliberately non-privileged; put a reverse proxy or the host's own 80/443 in front of
# those too if you want them there). Port 80 is only actually bound while
# [LetsEncrypt] challenge_type=http-01 is enabled and an obtain/renew is in flight —
# harmless to expose even when unused.
EXPOSE 25 465 143 993 80 5000 5001
+23 -6
View File
@@ -5,6 +5,8 @@
# (127.0.0.1:11333, the /checkv2 scanning API — see internal/mailstore/rspamd.go) is
# already exactly what [Rspamd] url defaults to in settings.ini. Just set
# [Rspamd] enabled = true after first boot (see docker-deploy/README.md) and restart.
# No redis here by design — see Dockerfile.aio for the variant that adds it (Bayes
# learning + greylisting).
#
# Build from the repo root:
# docker build -f docker-deploy/Dockerfile.rspamd -t mailgoserver-rspamd .
@@ -21,7 +23,7 @@ FROM debian:bookworm-slim
# package, which tends to lag several releases behind — this is genuinely the latest
# stable release, matching what was asked for.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata curl gnupg lsb-release \
ca-certificates tzdata curl gnupg lsb-release libcap2-bin \
&& mkdir -p /usr/share/keyrings \
&& curl -fsSL https://rspamd.com/apt-stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/rspamd.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/rspamd.gpg] https://rspamd.com/apt-stable/ $(lsb_release -cs) main" \
@@ -29,15 +31,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get update && apt-get install -y --no-install-recommends rspamd \
&& rm -rf /var/lib/apt/lists/*
# Redirect rspamd's own persistent state (fuzzy storage, DNS/maps cache) into
# server_data/rspamd at container-start (see entrypoint-rspamd.sh, which creates and
# chowns that directory — it doesn't exist yet at build time, since it lives on the
# volume) rather than the package default /var/lib/rspamd, so this whole image only
# ever needs one volume mounted: server_data/, matching mailgoserver's own layout.
RUN mkdir -p /etc/rspamd/local.d && printf 'dbdir = "/app/server_data/rspamd";\n' > /etc/rspamd/local.d/options.inc
COPY --from=build /out/mailgoserver /usr/local/bin/mailgoserver
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/mailgoserver
COPY docker-deploy/entrypoint-rspamd.sh /usr/local/bin/entrypoint-rspamd.sh
RUN chmod +x /usr/local/bin/entrypoint-rspamd.sh
# /app/data: same as the standalone image (settings.ini, DB, mailstore, keys, certs).
# /var/lib/rspamd: rspamd's own Bayes/fuzzy-hash storage, kept persistent separately so
# rebuilding the image doesn't reset spam-learning state.
WORKDIR /app/data
VOLUME ["/app/data", "/var/lib/rspamd"]
# rspamd's own .deb postinst already creates the "rspamd" system user; mailgoserver
# gets one here for the same reason as the standalone Dockerfile — neither process runs
# as root (see entrypoint-rspamd.sh for how each is dropped to its own user).
RUN useradd --system --create-home --home-dir /app --shell /usr/sbin/nologin mailgoserver \
&& mkdir -p /app/server_data \
&& chown -R mailgoserver:mailgoserver /app \
&& chmod 755 /app
WORKDIR /app
VOLUME ["/app/server_data"]
# Deliberately stays root here (unlike the standalone Dockerfile's USER mailgoserver) —
# the entrypoint script itself needs root just long enough to chown server_data/rspamd
# and drop privileges for each child process individually; see entrypoint-rspamd.sh.
# Port 80 is only actually bound while [LetsEncrypt] challenge_type=http-01 is enabled
# and an obtain/renew is in flight — harmless to expose even when unused.
+88 -34
View File
@@ -1,50 +1,68 @@
# Docker deployment
Two independent images, both built from the same source tree:
Three independent images, all built from the same source tree:
- **`Dockerfile`** — mailgoserver only.
- **`Dockerfile.rspamd`** — mailgoserver + the latest [rspamd](https://rspamd.com) in the
*same* container, already wired together (see below). Use this one if you want
stronger spam filtering than the built-in heuristic score alone.
*same* container, already wired together. Use this if you want stronger spam
filtering than the built-in heuristic score alone, but don't need Bayes learning or
greylisting.
- **`Dockerfile.aio`** — mailgoserver + rspamd + redis, the full spam-filtering stack:
redis backs rspamd's Bayes classifier and greylisting module, neither of which work
without it. mailgoserver itself has no direct use for redis — it's a single-instance
app already backed by SQLite for everything, so this exists purely to make rspamd's
scoring meaningfully better, not to make mailgoserver itself faster or more scalable.
`docker-compose.yml` defines both as Compose **profiles** so a plain `docker compose up`
can't accidentally start both at once:
`docker-compose.yml` defines all three as Compose **profiles** so a plain `docker compose
up` can't accidentally start more than one at once:
```bash
cd docker-deploy
# mailserver only
docker compose --profile standalone up -d --build
# mailserver + rspamd, bundled
docker compose --profile with-rspamd up -d --build
docker compose --profile standalone up -d --build # mailserver only
docker compose --profile with-rspamd up -d --build # mailserver + rspamd
docker compose --profile all-in-one up -d --build # mailserver + rspamd + redis
```
Either way, the app itself now binds the real standard mail ports by default — 25
Either way, the app itself binds the real standard mail ports by default — 25
(SMTP), 465 (direct-TLS SMTP), 143 (IMAP), 993 (direct-TLS IMAP) — and the admin/webmail
UI on its usual non-privileged 5000/5001 (HTTP/HTTPS); put your own reverse proxy or a
`80:5000`/`443:5001` port mapping in front if you want those on 80/443 too (note port 80
is already published here for Let's Encrypt HTTP-01, see below — pick a different host
port for the web UI's 80 mapping if you use both). Binding the low mail ports needs no
extra capability here since the container runs as root. Copy `.env.example` to `.env` in
this folder to change any host-side port — useful if something else on the host already
owns 25/143/etc., or if you want to run both profiles side by side.
port for the web UI's 80 mapping if you use both). Copy `.env.example` to `.env` in this
folder to change any host-side port — useful if something else on the host already owns
25/143/etc., or if you want to run more than one profile side by side.
## Security: nothing here runs as root
None of the three images run mailgoserver, rspamd, or redis as root. Each gets its own
unprivileged system user (rspamd's and redis's own `.deb` packages create theirs;
mailgoserver's is created in the Dockerfile), and mailgoserver's binary is granted just
the one Linux capability it actually needs — `CAP_NET_BIND_SERVICE`, to bind ports
25/465/80 — via `setcap` at build time, rather than the whole container running as root
to get the same effect. `CAP_NET_BIND_SERVICE` is one of Docker's default capabilities,
so this needs no extra `--cap-add` at `docker run`/compose time.
The `Dockerfile.rspamd`/`Dockerfile.aio` entrypoint scripts (`entrypoint-rspamd.sh`,
`entrypoint-aio.sh`) do still start as root — only long enough to `chown` the
persistent subdirectories under `server_data/` for whichever user needs to write there,
then drop to that user (via `runuser`) for every actual process, mailgoserver included.
## What happens on first boot
There's no baked-in config. On first start, the binary generates a fresh
`settings.ini` with defaults (mirroring `internal/config/config.go`), a self-signed TLS
certificate, DKIM/mailstore master keys, and an empty SQLite database — all inside the
`/app/data` volume, so it survives container restarts/rebuilds. The web UI seeds one
admin account: username `admin`, password `Password123!`, and forces an immediate
username + password change on first login — see the main [README](../README.md) for
the full first-login walkthrough.
`server_data/settings.ini` with defaults (mirroring `internal/config/config.go`), a
self-signed TLS certificate, DKIM/mailstore master keys, and an empty SQLite database —
all inside the `server_data/` volume, so it survives container restarts/rebuilds. The
web UI seeds one admin account: username `admin`, password `Password123!`, and forces an
immediate username + password change on first login — see the main
[README](../README.md) for the full first-login walkthrough.
**Before using this for real mail**, exec into the container (or edit the volume from
the host) and update `settings.ini`:
```bash
docker exec -it mailgoserver sh -c 'vi /app/data/settings.ini'
docker exec -it mailgoserver sh -c 'vi /app/server_data/settings.ini'
docker restart mailgoserver
```
@@ -54,7 +72,7 @@ dashboard is reached at (`rp_id` can't be `localhost` once you're on a real doma
see the main README's WebAuthn note). There's no environment-variable override
mechanism — `settings.ini` in the volume is the one source of config truth.
## Enabling rspamd (the `with-rspamd` profile)
## Enabling rspamd (`with-rspamd` and `all-in-one`)
The bundled rspamd's default config already listens on `127.0.0.1:11333` for scanning
requests — exactly what `[Rspamd] url` defaults to in `settings.ini`, and since both
@@ -73,9 +91,15 @@ reject_score = 15
additive, not a replacement, and if it's ever unreachable, mail still flows on the
heuristic score alone (rspamd errors are logged, never fatal to delivery).
This bundle intentionally skips Redis — rspamd runs fine without it for SPF/DKIM/RBL/
regexp-based scoring, but Bayes learning and greylisting need it. Add a `redis` service
to `docker-compose.yml` and point rspamd's `redis.conf` at it if you need those.
`with-rspamd` intentionally skips redis — rspamd runs fine without it for SPF/DKIM/RBL/
regexp-based scoring, but Bayes learning and greylisting need it. If you want those,
use `all-in-one` instead: its rspamd is already pre-configured (`local.d/redis.conf`,
`classifier-bayes.conf`, `greylist.conf` baked into the image) to use the redis running
alongside it — nothing to turn on beyond `[Rspamd] enabled = true` above. That redis
instance is loopback-only and has no exposed port or password: the only two processes
that can ever reach it are rspamd and mailgoserver, both inside the same container's
network namespace. If you publish 6379 yourself for some other reason, add a
`requirepass` to the `redis-server` command in `entrypoint-aio.sh` first.
## Let's Encrypt HTTP-01 (no DNS provider needed)
@@ -98,21 +122,51 @@ SMTP/IMAP, DNS-01 (or a real custom cert) for the web UI.
## Persistence
Every profile mounts exactly one volume:
| Volume | What's in it |
|---|---|
| `mailserver-data` / `mailserver-rspamd-data` | `settings.ini`, the SQLite DB, encrypted mailbox storage, DKIM/mailstore master keys, TLS certs, the CSRF app secret — everything mailgoserver itself owns. |
| `rspamd-data` (rspamd profile only) | rspamd's own Bayes/fuzzy-hash storage, so spam-learning state survives image rebuilds. |
| `mailserver-data` (`standalone`) | `settings.ini`, the SQLite DB, encrypted mailbox storage, DKIM/mailstore master keys, TLS certs, the CSRF app secret — everything mailgoserver itself owns. |
| `mailserver-rspamd-data` (`with-rspamd`) | The above, plus `rspamd/` rspamd's own fuzzy-hash storage and DNS/maps cache. |
| `mailserver-aio-data` (`all-in-one`) | The above, plus `redis/` — redis's RDB snapshot, so Bayes-learning and greylist state survive restarts too. |
Back up the `*-data` volume like you would the equivalent bare-metal `server_data/`
directory — losing the mailstore master key makes all stored mail unrecoverable, same
as a non-Docker install.
Back up the volume like you would the equivalent bare-metal `server_data/` directory —
losing the mailstore master key makes all stored mail unrecoverable, same as a
non-Docker install. (Redis's own state isn't precious the same way — losing it just
means rspamd re-learns Bayes classifications and greylist history from scratch, not
anything mail-data-critical.)
## Keeping the base image and packages patched
None of these images auto-update their own OS packages (Debian, rspamd, redis) while
running — a container's filesystem is meant to be rebuilt from a fresh base, not patched
in place, so the reliable way to pick up security fixes is rebuilding periodically:
```bash
docker compose --profile <profile> build --pull --no-cache
docker compose --profile <profile> up -d
```
Put that on a host-level cron/systemd timer (weekly is reasonable), or use a tool like
[Renovate](https://docs.renovatebot.com/) or Watchtower against this repo/image if you
want it automated end-to-end. Running `apt-get upgrade` on a schedule *inside* the
running container was considered and deliberately left out — it would only patch that
one running instance until its next recreation (at which point the image's original,
un-patched packages come back anyway), doesn't rebuild the Go binary itself, and adds a
cron daemon + package-manager attack surface to a container that's otherwise trying to
run three unprivileged, single-purpose processes. Rebuilding the image is both more
thorough and simpler.
## Logs / health
```bash
docker compose --profile standalone logs -f # or --profile with-rspamd
docker compose --profile standalone logs -f # or --profile with-rspamd / all-in-one
docker inspect --format '{{.State.Health.Status}}' mailgoserver
```
Both images expose `GET /health` (used by the container `HEALTHCHECK`), matching the
JSON the admin dashboard's own health check reads.
All three images log to stdout/stderr only (no log file inside the container) — this is
the standard Docker pattern (`docker logs`, or point your log driver/aggregator at the
container) rather than something to volume-mount or rotate yourself.
All three images expose `GET /health` (used by the container `HEALTHCHECK`), matching
the JSON the admin dashboard's own health check reads.
+36 -13
View File
@@ -1,16 +1,22 @@
# Two independent setups, selected with --profile so they don't both start by accident:
# Three independent setups, selected with --profile so they don't both start by accident:
#
# docker compose --profile standalone up -d --build # mailserver only
# docker compose --profile with-rspamd up -d --build # mailserver + rspamd, same container
# docker compose --profile all-in-one up -d --build # mailserver + rspamd + redis (Bayes/greylisting)
#
# Both default to the standard mail ports on the host (25/465/143/993) — the app itself
# now binds those directly, no port remapping needed — plus port 80 (only actually used
# while Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and
# the app's own non-privileged web ports (5000/5001; put a reverse proxy or your own
# 80/443 mapping in front of those if you want the dashboard on standard web ports too —
# note port 80 is already claimed here for HTTP-01 if you enable it). Only run one profile at a time
# unless you've overridden the host ports for one of them (see .env.example) — they'd
# otherwise both try to bind the same host ports.
# Every profile mounts exactly one volume — server_data/ — since every persistent thing
# any of these processes own (mailgoserver's own data, rspamd's dbdir, redis's RDB
# snapshot) lives under that single directory now; see the Dockerfiles' comments.
#
# All three default to the standard mail ports on the host (25/465/143/993) — the app
# itself binds those directly (via a Linux file capability, not by running as root — see
# the Dockerfiles), no port remapping needed — plus port 80 (only actually used while
# Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and the
# app's own non-privileged web ports (5000/5001; put a reverse proxy or your own 80/443
# mapping in front of those if you want the dashboard on standard web ports too — note
# port 80 is already claimed here for HTTP-01 if you enable it). Only run one profile at
# a time unless you've overridden the host ports for it (see .env.example) — they'd
# otherwise all try to bind the same host ports.
services:
mailserver:
build:
@@ -28,7 +34,7 @@ services:
- "${WEB_HTTP_PORT:-5000}:5000"
- "${WEB_HTTPS_PORT:-5001}:5001"
volumes:
- mailserver-data:/app/data
- mailserver-data:/app/server_data
mailserver-rspamd:
build:
@@ -46,10 +52,27 @@ services:
- "${WEB_HTTP_PORT:-5000}:5000"
- "${WEB_HTTPS_PORT:-5001}:5001"
volumes:
- mailserver-rspamd-data:/app/data
- rspamd-data:/var/lib/rspamd
- mailserver-rspamd-data:/app/server_data
mailserver-aio:
build:
context: ..
dockerfile: docker-deploy/Dockerfile.aio
container_name: mailgoserver-aio
restart: unless-stopped
profiles: ["all-in-one"]
ports:
- "${SMTP_PORT:-25}:25"
- "${SMTP_TLS_PORT:-465}:465"
- "${IMAP_PORT:-143}:143"
- "${IMAP_TLS_PORT:-993}:993"
- "${ACME_HTTP_PORT:-80}:80"
- "${WEB_HTTP_PORT:-5000}:5000"
- "${WEB_HTTPS_PORT:-5001}:5001"
volumes:
- mailserver-aio-data:/app/server_data
volumes:
mailserver-data:
mailserver-rspamd-data:
rspamd-data:
mailserver-aio-data:
+16 -4
View File
@@ -1,19 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
# Starts rspamd in the background, then hands off to mailgoserver as the container's
# main (foreground) process.
# Starts rspamd (under its own unprivileged system user, created by the rspamd .deb's
# own postinst) in the background, then hands off to mailgoserver — also dropped to its
# own unprivileged user — as the container's main (foreground) process. This script
# itself keeps running as root only long enough to create/chown the persistent
# directories below and drop privileges for each child; neither rspamd nor mailgoserver
# ever run as root themselves (mailgoserver's binary carries just the
# CAP_NET_BIND_SERVICE file capability it needs for ports 25/465/80 — see the
# Dockerfile — which a non-root exec of it still picks up).
#
# rspamd's own persistent state (fuzzy storage, DNS/maps cache — its "dbdir") is
# redirected into server_data/rspamd so the whole container only has one thing to
# volume-mount: server_data/, matching mailgoserver's own layout.
mkdir -p /app/server_data/rspamd
chown -R rspamd:rspamd /app/server_data/rspamd
# ponytail: no supervisor / restart-on-crash for rspamd here — if it dies mid-run, mail
# keeps flowing without the extra scoring rather than the container crashing (the
# built-in heuristic score in internal/mailstore/spam.go always runs regardless, and
# CheckRspamd swallows connection errors rather than rejecting mail over them — see
# internal/mailstore/rspamd.go). Add s6-overlay or supervisord if unattended rspamd
# uptime matters more than that.
rspamd -f &
runuser -u rspamd -- rspamd -f &
# Give rspamd's normal worker a moment to bind :11333 before the first message can
# possibly arrive — purely cosmetic, mailgoserver degrades gracefully either way.
sleep 1
exec mailgoserver --host 0.0.0.0 "$@"
exec runuser -u mailgoserver -- mailgoserver --host 0.0.0.0 "$@"
+18
View File
@@ -0,0 +1,18 @@
Implemented for real 2026-08-16 — see `docker-deploy/`:
- `Dockerfile.aio` + `entrypoint-aio.sh` — mailgoserver + rspamd + redis in one
container (redis backs rspamd's Bayes classifier + greylisting; mailgoserver itself
has no use for redis, it's a single-instance app already backed by SQLite).
- `docker-compose.yml`'s `all-in-one` profile: `docker compose --profile all-in-one up -d --build`
- Single volume everywhere now (`server_data/`) — settings.ini, TLS certs, DKIM/mailstore
keys, the SQLite DB, rspamd's dbdir, and redis's RDB snapshot all live under it (see
`internal/config/config.go` + `main.go`'s server_data-relative defaults).
- Nothing runs as root — each process (mailgoserver, rspamd, redis) has its own
unprivileged user; mailgoserver's binary gets just `CAP_NET_BIND_SERVICE` via `setcap`
instead of the whole container running as root.
- Auto-updating installed packages on a schedule *inside* the running container was
considered and left out (see `docker-deploy/README.md`'s "Keeping the base image and
packages patched" section) — rebuilding the image periodically is the recommended
approach instead; happy to build a scheduled rebuild/CI job if wanted.
The draft supervisord/Alpine sketch that used to be here is superseded by the above.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
# All-in-one: redis, then rspamd (which uses that redis for Bayes classification and
# greylisting — see the local.d config baked into Dockerfile.aio), then mailgoserver as
# the container's main (foreground) process. Each runs under its own unprivileged
# system user (redis/rspamd from their own .deb postinst; mailgoserver's created in the
# Dockerfile) — this script itself keeps running as root only long enough to
# create/chown the persistent directories below and drop privileges for each child; see
# Dockerfile/Dockerfile.aio's comments on why that's still safe for the one process
# (mailgoserver) that actually needs a privileged port.
#
# Everything persistent — mailgoserver's own data, rspamd's dbdir, and redis's RDB
# snapshot — lives under server_data/, so this whole bundle only has one thing to
# volume-mount.
mkdir -p /app/server_data/rspamd /app/server_data/redis
chown -R rspamd:rspamd /app/server_data/rspamd
chown -R redis:redis /app/server_data/redis
# Loopback-only (never published as a container port — see Dockerfile.aio) and no
# password: the only two processes that can ever reach it are rspamd and mailgoserver,
# both inside this same container's network namespace. If you publish 6379 yourself for
# some reason, add `requirepass` here first.
runuser -u redis -- redis-server --bind 127.0.0.1 -::1 --protected-mode yes \
--dir /app/server_data/redis --daemonize no &
# Give redis a moment to start listening before rspamd (which needs it for Bayes/
# greylist) connects.
sleep 1
# ponytail: no supervisor / restart-on-crash for redis/rspamd here — if either dies
# mid-run, mail keeps flowing on the built-in heuristic score alone rather than the
# container crashing (internal/mailstore/spam.go always runs regardless, and
# CheckRspamd swallows connection errors rather than rejecting mail over them — see
# internal/mailstore/rspamd.go). Add s6-overlay or supervisord if unattended uptime for
# the scoring stack matters more than that.
runuser -u rspamd -- rspamd -f &
# Give rspamd's normal worker a moment to bind :11333 before the first message can
# possibly arrive — purely cosmetic, mailgoserver degrades gracefully either way.
sleep 1
exec runuser -u mailgoserver -- mailgoserver --host 0.0.0.0 "$@"
+2 -2
View File
@@ -72,8 +72,8 @@ var defaults = []struct {
{"TLS", []defaultKV{
{"", "", "TLS/SSL certificate configuration"},
{"", "", "The 'custom' certificate: self-signed on first run, or your own uploaded cert/key"},
{"TLS_CERT_FILE", "ssl_certs/server.crt", ""},
{"TLS_KEY_FILE", "ssl_certs/server.key", ""},
{"TLS_CERT_FILE", "server_data/ssl_certs/server.crt", ""},
{"TLS_KEY_FILE", "server_data/ssl_certs/server.key", ""},
{"", "", "Which certificate each TLS-serving listener uses: custom, letsencrypt_dns"},
{"", "", "(the [LetsEncrypt] DNS-01 cert), or letsencrypt_http (the [LetsEncryptHTTP]"},
{"", "", "HTTP-01 cert). Independent per listener - e.g. run the HTTP-01 cert on"},
+1 -1
View File
@@ -151,7 +151,7 @@ func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Expected a .json service account key file"})
return
}
acmeDir := filepath.Join(filepath.Dir(a.ConfigPath), "server_data", "acme")
acmeDir := filepath.Join(a.Root, "server_data", "acme")
os.MkdirAll(acmeDir, 0o755)
filePath := filepath.Join(acmeDir, fmt.Sprintf("gcloud-sa-%d.json", time.Now().Unix()))
out, err := os.Create(filePath)
+2 -2
View File
@@ -163,7 +163,7 @@ func (a *App) uploadTLSFile(w http.ResponseWriter, r *http.Request, field, force
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid file extension"})
return
}
sslDir := filepath.Join(filepath.Dir(a.ConfigPath), "ssl_certs")
sslDir := filepath.Join(a.Root, "server_data", "ssl_certs")
os.MkdirAll(sslDir, 0o755)
filePath := filepath.Join(sslDir, fmt.Sprintf("server%d.%s", time.Now().Unix(), forcedExt))
out, err := os.Create(filePath)
@@ -192,7 +192,7 @@ func (a *App) testAttachmentsPath(w http.ResponseWriter, r *http.Request) {
return
}
if !filepath.IsAbs(path) {
path = filepath.Join(filepath.Dir(a.ConfigPath), path)
path = filepath.Join(a.Root, path)
}
if err := os.MkdirAll(path, 0o755); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": err.Error()})
+10 -4
View File
@@ -31,6 +31,13 @@ type App struct {
Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go)
Cfg *ini.File
ConfigPath string
// Root is the app's working directory at startup (os.Getwd() in main.go) — every
// relative path this package resolves on its own (uploaded cert/key files, the
// ACME data dir, the attachments-path tester) is joined against this, not against
// ConfigPath's own directory, so it always matches how main.go resolves the same
// kind of path (mailstore, attachments, TLS certs) regardless of where -config
// happens to point.
Root string
Logger *toolbox.Logger
SMTPUp func() bool // reports whether the SMTP listeners are currently running
@@ -58,12 +65,11 @@ type App struct {
// filesystem (embed.go), not disk, so no directory paths are needed for them.
// appSecret is loaded by the caller via LoadOrCreateAppSecret, mirroring how
// mailstore's master key is loaded in main.go and threaded in rather than resolved
// internally (both are file paths relative to the app's root working directory,
// which this package doesn't otherwise know).
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
// internally (both are file paths relative to root — see the App.Root doc comment).
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath, root string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger)
a := &App{
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Root: root, Logger: logger, SMTPUp: smtpUp,
pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret,
}
if err := a.loadTemplates(); err != nil {
+1 -1
View File
@@ -140,7 +140,7 @@ func newTestApp(t *testing.T) *App {
if err != nil {
t.Fatalf("LoadOrCreateAppSecret: %v", err)
}
app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, dir, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
if err != nil {
t.Fatalf("New: %v", err)
}
+9 -6
View File
@@ -35,7 +35,10 @@ func main() {
port := flag.Int("port", 5000, "Web server port")
debug := flag.Bool("debug", false, "Enable debug mode")
initData := flag.Bool("init-data", false, "Initialize sample data and exit")
configFlag := flag.String("config", "settings.ini", "Configuration file path")
// Defaults inside server_data/ (along with the DB, mailstore, keys, and certs
// generated below) so a Docker deployment only needs to volume-mount that one
// directory to persist everything — nothing is left sitting next to the binary.
configFlag := flag.String("config", "server_data/settings.ini", "Configuration file path")
flag.Parse()
portFlagSet := false
flag.Visit(func(f *flag.Flag) {
@@ -120,10 +123,10 @@ func main() {
// manager has obtained anything yet.
customCertFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String())
customKeyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String())
dnsCertFile := absPath(root, "ssl_certs/letsencrypt_dns.crt")
dnsKeyFile := absPath(root, "ssl_certs/letsencrypt_dns.key")
httpCertFile := absPath(root, "ssl_certs/letsencrypt_http.crt")
httpKeyFile := absPath(root, "ssl_certs/letsencrypt_http.key")
dnsCertFile := absPath(root, "server_data/ssl_certs/letsencrypt_dns.crt")
dnsKeyFile := absPath(root, "server_data/ssl_certs/letsencrypt_dns.key")
httpCertFile := absPath(root, "server_data/ssl_certs/letsencrypt_http.crt")
httpKeyFile := absPath(root, "server_data/ssl_certs/letsencrypt_http.key")
for _, pair := range [][2]string{
{customCertFile, customKeyFile}, {dnsCertFile, dnsKeyFile}, {httpCertFile, httpKeyFile},
} {
@@ -300,7 +303,7 @@ func main() {
os.Exit(1)
}
app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load, appSecret)
app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, root, toolbox.GetLogger("web"), smtpRunning.Load, appSecret)
if err != nil {
logger.Error("init web UI: %v", err)
os.Exit(1)