Overview#
My mail runs on mailcow, a Docker-based mail stack, on the same small server as this website. Both sit behind Traefik. Sharing one server with a mail stack that expects to own it takes a few deliberate choices: who gets the web ports, where certificates come from, and how to change upstream software without losing the ability to update it.
This page is about those choices. For the server as a whole, including full disk encryption, automated patching, encrypted off-site backups, vulnerability scanning and the sandbox I use for AI-assisted work, see One Repo, One Server.
One front door for the web, direct ports for mail#
Mailcow ships with its own nginx that wants ports 80 and 443. On a shared server that’s one owner too many, so Traefik owns the web ports and mailcow’s nginx doesn’t publish any:
- Web traffic (webmail, ActiveSync, autodiscover) goes through Traefik.
- Mail traffic (SMTP on 25/465/587, IMAP on 993) goes straight to postfix and dovecot, because those protocols don’t belong behind a web proxy.
ActiveSync needs one extra trick. Phones call /Microsoft-Server-ActiveSync, but SOGo serves it under /SOGo/, so Traefik routes it to SOGo directly and rewrites the path:
http:
routers:
sogo-eas:
rule: "Host(`mail.bufon.nl`) && PathPrefix(`/Microsoft-Server-ActiveSync`)"
priority: 1000
entryPoints: [websecure]
tls:
certResolver: le
service: sogo-eas
middlewares: [sogo-eas-rewrite]
middlewares:
sogo-eas-rewrite:
replacePathRegex:
regex: "^/Microsoft-Server-ActiveSync(.*)"
replacement: "/SOGo/Microsoft-Server-ActiveSync$1"One certificate source for web and mail#
Because Traefik handles the web side, mailcow’s own Let’s Encrypt client is switched off. That left IMAP and SMTP on a self-signed certificate: mail apps warned about it, and other mail servers couldn’t verify mine.
The fix is a small sidecar that watches Traefik’s certificate store, copies the mail.bufon.nl certificate into mailcow, and restarts the mail services whenever it changes. It lives in a docker-compose.override.yml, so mailcow’s own files stay untouched:
services:
certdumper:
image: ghcr.io/kereis/traefik-certs-dumper:1.8.28
command: --restart-containers ${COMPOSE_PROJECT_NAME}-postfix-mailcow-1,${COMPOSE_PROJECT_NAME}-dovecot-mailcow-1,${COMPOSE_PROJECT_NAME}-nginx-mailcow-1
network_mode: none
restart: always
volumes:
- /opt/bufon/traefik/acme:/traefik:ro
- ./data/assets/ssl:/output:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOMAIN=${MAILCOW_HOSTNAME}Renewals now flow through on their own: Traefik renews, the sidecar notices, and the mail services pick up the new certificate within a minute.
The trade-off. To restart the mail containers, the sidecar needs the Docker socket. The :ro on that mount only makes the socket file read-only; anything that can talk to the Docker API can still start a privileged container, which means root on the host. So this small helper is effectively root. I accept that because it has no network at all (network_mode: none) and only does one thing. It also runs a pinned version: mailcow’s update.sh pulls every image in the compose project, the override included, so with latest each mail update would quietly bring a new certdumper too. Now a new release arrives as a Dependabot pull request, and nothing with root-equivalent access on my server changes without my review.
The website behind the same front door#
The site joins Traefik’s network like any other service and describes its own routing with labels. It’s a production build: Hugo turns the content into static files at build time and an unprivileged nginx serves them on port 8080, so nothing in the container runs as root and there’s no development server on the internet.
name: bufon
services:
web:
build: .
image: bufon-website
container_name: bufon
restart: always
labels:
- "traefik.enable=true"
# Only bufon.nl; www.bufon.nl redirects to it
- "traefik.http.routers.hugo.rule=Host(`bufon.nl`)"
- "traefik.http.routers.hugo.entrypoints=websecure"
- "traefik.http.routers.hugo.tls.certresolver=le"
- "traefik.http.routers.hugo.tls.domains[0].main=bufon.nl"
- "traefik.http.routers.hugo.tls.domains[0].sans=www.bufon.nl"
- "traefik.http.routers.hugo.middlewares=bufon-headers@file"
- "traefik.http.services.hugo.loadbalancer.server.port=8080"
networks:
- traefik_proxy
networks:
traefik_proxy:
external: trueThe bufon-headers middleware adds HSTS, a Content Security Policy that blocks framing, and the other usual security headers, so every response gets them without the site having to know.
Staying upgradable#
Mailcow updates itself with its own update.sh, which expects a clean checkout. So I never fork it. My changes live in my own repository instead: the override file above, plus a small patch for the few upstream files I had to touch. After an update, the patch re-applies with one git apply, and anything that conflicts shows up immediately instead of being silently overwritten.

