# syntax=docker/dockerfile:1 # # SelfPost — single Debian-slim image running postfix + panel under # supervisord. Build from the repository root: # # docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . # ---- build stage ------------------------------------------------------------- FROM golang:1.26-trixie AS build WORKDIR /src # Version stamped into both binaries; MUST match the image tag. ARG VERSION=dev # Module metadata first for layer caching. COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd COPY internal ./internal ENV CGO_ENABLED=0 RUN go vet ./... \ && go build -trimpath \ -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \ -o /out/panel ./cmd/panel \ && go build -trimpath \ -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \ -o /out/selfpost-backup ./cmd/selfpost-backup # ---- runtime stage ----------------------------------------------------------- FROM debian:trixie-slim AS runtime ENV DEBIAN_FRONTEND=noninteractive # Preseed Postfix so its install is non-interactive and yields a working # main.cf. The real relay configuration is generated by the panel. # (postfix 3.10.13-0+deb13u1, debian/templates:14 and :50: # postfix/main_mailer_type and postfix/mailname are the questions the install # asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No # configuration".) # openssl is named because postfix-cert-reload.sh runs it, not left to arrive # with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3, # debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings # saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207, # debian/sasl2-bin.install:3, usr/sbin) and depends on db-util # (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins # (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in # libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of # libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which # the panel runs (internal/app/sasl.go); that db-util ships that path on # trixie is not in a source read here - the image build and the e2e sasldb # test say whether it does. # No DKIM package is installed: the panel signs itself (internal/milter, # sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left # the image in 2.8.0 (docs/plans/panel-dkim-signing.md). # systemd-standalone-sysusers is named so that cron-daemon-common, which # logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron | # anacron | cron-daemon | systemd-sysv"), creates its group with it rather # than with the whole of systemd, the first alternative of its "Depends: # systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the # image, 2026-10-15). Nothing here runs systemd. RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ && apt-get update \ && apt-get install -y --no-install-recommends \ postfix \ sasl2-bin \ libsasl2-modules \ db-util \ supervisor \ logrotate \ ca-certificates \ openssl \ curl \ systemd-standalone-sysusers \ && rm -rf /var/lib/apt/lists/* # Debian's postfix runs its chroot sync on every "postfix check" and "postfix # start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/ # debian-run-configure-instance-from-create-missing.patch); with no service # chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and # the like into the queue under /data on each start. SYNC_CHROOT empty turns # it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138). RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix # Unprivileged user for the panel process, with ids of its own outside the # system range for the same reason as selfpost's below: its files under # /data show on the host by number, and a number from that range is a # system group of the host's too (useradd gave the panel group 998 in the # trixie image, systemd-network on the test server's host; bookworm's # 999:999 was the host's systemd-journal group). The entrypoint gives /data # back to it by name on every start, so files of the old ids follow. RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel # Shared group bridging the unprivileged services: the panel writes the # application SASL accounts (sasldb2) and the Postfix sender map and opens the # milter sockets, while Postfix (another user) must read and reach them. # Membership in this group - plus setgid dirs under /data and /run/selfpost # (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and # connect to the panel's milters. The DKIM keys are the panel's alone. (The # supervisor control socket has a group of its own, `supervisor`, below.) # The id is fixed, because the host sees it: an operator gives the TLS # private key on the host to this group by number, so that the panel can put # it into a full backup (guide § Taking a full backup). It lies outside the # range the distribution hands out to system groups, 100-999 (adduser 3.152, # /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999"; # passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both # read in the image, 2026-10-15), so no package can take it, and far above # the 1000 a host starts its users' groups at, so a host is unlikely to have # a group of that number that would read the key too. Until 2.7.0 it was # 998, inside that range: in the trixie image systemd took 998 for # systemd-network and the build failed, and on the test server's host 998 # is systemd-network too (seen 2026-10-15). # groupadd(8) refuses a --gid already in use, so an id taken after all would # fail this build rather than share the group. RUN groupadd --system --gid 10998 selfpost \ && usermod -aG selfpost panel \ && usermod -aG selfpost postfix # The panel reads the mail queue with `postqueue -p` (and the status page's # queue card). postqueue is setgid postdrop, which normally gives it the group # needed to reach Postfix's showq socket - but the documented deployment runs # with `no-new-privileges`, which disables setgid transitions, so the panel # would always see "Permission denied". Making `panel` a real member of postdrop # grants the same access without relying on a setgid escalation the hardening # deliberately forbids. The same membership is what lets the panel submit its # own notifications with sendmail(1) - postdrop, too, is setgid and would fail # to write the maildrop directory (internal/notify/mailer.go). It does not let # the panel bypass any Postfix restriction that a local user does not already # have through the world-executable sendmail. # (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are # root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix # 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. Linux, # Documentation/userspace-api/no_new_privs.rst: # with no_new_privs, execve() no longer changes the gid for a setgid file.) RUN usermod -aG postdrop panel # The supervisord control socket's group (supervisord.conf, [unix_http_server]): # the panel alone - not Postfix's user, which shares `selfpost` for the files # under /data. RUN groupadd --system supervisor \ && usermod -aG supervisor panel # Runtime directories: milter sockets and the consolidated persistent root. RUN mkdir -p /run/selfpost /data \ && chown panel:panel /run/selfpost /data COPY --from=build /out/panel /usr/local/bin/panel COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup # Licence text shipped with the image (AGPL-3.0 conveyance). The panel also # serves the same text at /license from an embedded copy. OFL.txt is the SIL # Open Font License 1.1 that must travel with the IBM Plex WOFF2 files # embedded in the panel binary (NOTICE). COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt # File modes are pinned here so a build context copied from a checkout without # POSIX permissions (e.g. Windows tar sync) cannot land group-writable config # that logrotate would silently ignore — see docs/development.md § Building # binaries and the image. COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf # Published submission ports: 465 (smtps, primary) and 587 (submission, optional), # inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080. # Outbound delivery dials remote MXs on 25 as a client even when inbound is off. EXPOSE 8080 465 587 25 # Liveness probe: panel HTTP plus mail-path processes (panel, postfix). # Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations. HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 # The entrypoint fixes /data ownership (bind mount) as root, then execs # supervisord, which becomes PID 1 and owns process supervision. ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]