Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.
Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.
Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.
Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.
Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
8.7 KiB
YAML
154 lines
8.7 KiB
YAML
|
|
# SelfPost — default deployment, reverse-proxy = Apache.
|
|
#
|
|
# This file only runs SelfPost itself. Apache is assumed to already be installed
|
|
# on the HOST (the target audience for this project typically runs Apache there
|
|
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
|
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
|
# docs/guide.md "Reverse proxy (mandatory)" for the certbot steps that produce the PEM files
|
|
# this compose file mounts.
|
|
#
|
|
# Usage:
|
|
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
|
# ./certs directories, or adjust the paths below.
|
|
# 2. Fill in .env (at minimum SELFPOST_HOSTNAME). Put PEM files in ./certs —
|
|
# TLS paths are fixed in this file to match that bind mount, not .env.
|
|
# 3. docker compose up -d
|
|
#
|
|
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
|
# version against the running binary's version, so ":latest" would make that
|
|
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
|
# The 2.x line is not on ghcr.io yet: build it locally under this exact name
|
|
# (README, Quick start) and Compose will use it without pulling.
|
|
|
|
services:
|
|
selfpost:
|
|
image: ghcr.io/mixeme/selfpost:2.12.0
|
|
restart: unless-stopped
|
|
environment:
|
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
|
# Path Postfix reads inside the container — matches the certs bind mount
|
|
# below. Point these at your reverse-proxy's PEM output.
|
|
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
|
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
|
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
|
|
# the primary 465/smtps listener.
|
|
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
|
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
|
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
|
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
|
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
|
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
|
|
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
|
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
|
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
|
# Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
|
|
# configured later from the panel itself.
|
|
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
|
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
|
# How long the send log keeps rows before the background sweep deletes
|
|
# them — the main driver of /data growth over time.
|
|
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
|
INBOUND_SPAM_LOG_RETENTION_DAYS: "${INBOUND_SPAM_LOG_RETENTION_DAYS:-90}"
|
|
# Sliding idle timeout for the panel login session, in days.
|
|
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
|
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
|
# report what the internet sees rather than what this host's own stub
|
|
# resolver synthesises. Empty uses public defaults.
|
|
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
|
|
# The zone of every time the panel shows and of mail.log's stamps, e.g.
|
|
# Europe/Moscow. Empty is UTC.
|
|
TZ: "${TZ:-}"
|
|
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
|
|
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
|
# client spoof its rate-limit key.
|
|
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
|
volumes:
|
|
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
|
|
# sender map, setup token. Back this up (panel button or the
|
|
# selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
|
|
# here too (./data/log, rotated daily, 14 files kept) so the delivery
|
|
# lines that resolve a queued send-log row survive a container recreate;
|
|
# it is diagnostics and is left out of backups.
|
|
- ./data:/data
|
|
# Read-only project root for full backups: docker-compose.yml, .env, and
|
|
# certs/ are archived alongside /data (architecture.md § Persistence).
|
|
- .:/selfpost-deploy:ro
|
|
# Read-only: SelfPost only ever reads certificates, never manages them.
|
|
# ./certs holds fullchain.pem and privkey.pem: the files themselves, or
|
|
# links to where your reverse proxy's certbot keeps them.
|
|
- ./certs:/etc/postfix/tls:ro
|
|
# With certbot on the host (docs/guide.md, "Reverse proxy (mandatory)", Apache):
|
|
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
|
|
# files are themselves relative links into archive/ - so the whole
|
|
# directory is mounted, at the same path, for the links to lead
|
|
# somewhere inside the container. Uncomment it then.
|
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
|
ports:
|
|
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
|
# published directly — mail traffic bypasses Apache entirely, it only ever
|
|
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
|
|
# NOT published here: Apache reaches it over the host network at
|
|
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
|
|
# reachable from the internet without TLS.
|
|
- "465:465"
|
|
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
|
# when the variable is true — see docs/guide.md "Ports".
|
|
- "587:587"
|
|
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
|
# when the variable is true.
|
|
- "25:25"
|
|
- "127.0.0.1:8080:8080"
|
|
# Hardening. SelfPost's entrypoint still needs to run as
|
|
# root very briefly to fix /data ownership and normalise permissions
|
|
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
|
# supervisord drops the panel to an unprivileged user — so this cannot be
|
|
# `user: panel` or a fully read-only rootfs without breaking that startup
|
|
# self-healing. What IS applied: no privilege escalation past what the
|
|
# image already grants, and every Linux capability dropped except the
|
|
# small set the root startup phase and Postfix genuinely need:
|
|
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
|
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
|
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
|
# dirs/files while still root (owner-check bypass);
|
|
# - FSETID — set the setgid bit (2750) on the shared /data dirs
|
|
# when the process gid differs from the dir's group;
|
|
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
|
# `panel` user; Postfix switches to its own users;
|
|
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
|
# - KILL — root signals processes of other uids: supervisord
|
|
# stops and restarts the panel (supervisor 4.2.5,
|
|
# supervisor/process.py:467, options.kill()), and
|
|
# Postfix's master ends its children, which run as
|
|
# `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
|
|
# master_sigdeath(); master_spawn.c:365,
|
|
# master_delete_children()). Without it kill() fails
|
|
# with EPERM although the caller is root (kill(2),
|
|
# capabilities(7); measured on the test server
|
|
# 2026-10-16: root with SETUID/SETGID and no KILL
|
|
# cannot kill a process of uid 65534). Until 2.8.0
|
|
# it also carried OpenDKIM's USR1 reload.
|
|
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
|
# self-healing; without them chmod fails with EPERM and the container
|
|
# crash-loops on start.
|
|
# Each capability above as capabilities(7) defines it (man7.org,
|
|
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
|
|
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
|
|
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
|
|
# no-new-privileges only changes execve() (Linux,
|
|
# Documentation/userspace-api/no_new_privs.rst): root may still call
|
|
# setuid()/setgid() with these capabilities.
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
cap_drop:
|
|
- ALL
|
|
cap_add:
|
|
- NET_BIND_SERVICE
|
|
- CHOWN
|
|
- FOWNER
|
|
- FSETID
|
|
- SETUID
|
|
- SETGID
|
|
- DAC_OVERRIDE
|
|
- KILL
|