architecture § OpenDKIM becomes § DKIM signing (keys, the selector table and when it is rewritten, the two sockets, what is signed and the limits, the old tree); the process table, start order, liveness, the mail-path diagram, § Sender check, Persistence and the restore Resync follow the code. security.md: the panel signs with the login's key, the keys are the panel's alone, group selfpost and CAP_KILL as they are now; OpenDKIM's accepted risks go; new ones: what sendmail(1) submits is signed as the server, Sender: is signed but not judged (the owner's decision), and go-milter keeps every header per session, so the signer's 1 MiB refusal does not bound the library's copy. guide: Health, the container log's dkim-signer lines, backups, the long-line limit, and upgrading to 2.8.0 - with the rollback to 2.7.x: its start reads the queue as its own postfix (uid 101) before it hands the queue over, so the hand-over by name is run in the 2.7.x image first (measured on a copy of the test server's queue: postsuper failed before, passed after). development, README, SECURITY, product, the assumptions register's header: what left the image. entrypoint.sh: a comment, the rollback note as the guide gives it. Plan stage 3 lines ticked, roadmap 12/14. Written by four agents, one document each, against a brief of what the code does; reviewed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 KiB
SelfPost — development
What this file is. How to build, test, document, and ship changes. Open work after 1.0 (1.x+) lives in roadmap.md and linked plans/. Product boundaries: product.md. As-built layout: architecture.md.
Resuming work
After /clear or a fresh chat:
- Read this file (process, docs rules, model routing).
- Open roadmap.md for the index of open work; follow the linked plan file for the active item. Accepted risks — security.md; as-built — architecture.md.
- Skim product.md if scope is in doubt.
- Continue from the next unchecked step in the active plan file (not the roadmap index).
History of closed phases is in git log and CHANGELOG.md,
not duplicated here.
Authorship and disclosure
Decided, not open for re-litigation. SelfPost is written by AI agents under a maintainer's direction, and the project says so rather than hiding it.
Concretely, this is what "says so" means, and none of it is an oversight to be tidied away later:
Co-Authored-By: Claude <model>trailers stay in commit messages, including the ~140 commits that predate v1.0.- The model routing table below is public, in a file the README links to.
- .cursor/rules/agent-rules.mdc ships in the repository.
- Process notes written for an agent — "after a context reset, pick an item
marked
agreed" in roadmap.md — stay as they are.
Why not quietly drop it. Once the trailers are in the history, removing the routing table or the rules file would not conceal authorship, it would only make the project look like it was trying to. Partial concealment reads worse than the plain statement, and the plain statement costs nothing: the code is reviewed, tested, and shipped under the same rules either way, and the security design records what was audited and what was accepted.
Revisit if: the disclosure ever conflicts with the licence or a downstream obligation — not because the convention around AI authorship shifts.
Model routing
| Kind of work | Model | Examples |
|---|---|---|
Security, infra, file permissions, Postfix/postqueue, open-relay risk |
Opus | mail.log under /data, entrypoint permissions, queue reconcile |
| UI / JS / CSS, templates, documentation (English), README | Sonnet | adaptive polling, this file's Documentation section |
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | Haiku | Makefile / release.yml comment fixes, deleting closed plan files |
| Security review (not authorship) | Fable | pre-release checklist (CHANGELOG [0.5.0] Security / security.md — done) |
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial mechanics → Haiku. Reviewers must not be the author of the code under review.
Composer → Opus → Fable (when a plan specifies it): Composer writes all implementation for that item (code, tests, docs); Opus does the technical review of the result; Fable does the security review. A plan's checklist overrides the default routing table for that item only.
Plan checklists
Open roadmap.md items with a linked plan file carry an Implementation checklist in plans/. After a context reset, work the active plan — not the roadmap prose — from the first unchecked line.
Checklist format
- [ ] Step description — **Model**(model from the routing table above).- Mark
[x]only in the commit that actually finishes the step. candidateitems: checklist may exist, but no code until the roadmap status is agreed.
Progress column in the roadmap index (0/N) counts every checklist line in
that plan (including docs and go vet / go test steps). Update N when the
checklist changes; update the numerator when steps are checked off.
Roadmap track: commit and version
For the current 1.x+ roadmap queue (roadmap.md index), stricter than the default «meaningful step» rule in § Commits and release build:
After each checklist step
- Check off the step in the plan file; bump
Progressin the roadmap index. - Append CHANGELOG.md under
[Unreleased]for that step. - Commit on
main(push / tag / Release only on explicit request).
After each roadmap stage (all checklist steps done; «Done when» satisfied)
- Version cut in one commit: rename
[Unreleased]to[X.Y.Z] - date, open a fresh[Unreleased], bump the image pin in deploy/docker-compose.yml. - Git tag
vX.Y.Zand publish the GitHub Release only on explicit request (see § Release image).
Planned version cuts (from pin 2.7.0; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
| 1 | queue-retries | 1.3.1 (PATCH) |
| 2 | inbound-relay | 1.4.0 |
| 3 | send-log-retention | 1.5.0 |
| 4 | domain-stats-auto-ratelimit | 1.6.0 |
| 5 | dmarc-reports | 1.7.0 |
| 6 | panel-docs | 1.8.0 |
| 7 | application auth IPs | 1.9.0 |
| 8 | port-25 Postfix startup fix | 1.9.1 (PATCH) |
| 9 | 2026-08-19 code review pass | 1.9.2 (PATCH) |
| 10 | panel-redesign prep (docs only, owner-requested cut) | 1.9.3 (PATCH) |
| 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | 1.9.4 (PATCH) |
| 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | 1.9.5 (PATCH) |
| 13 | panel-redesign | 2.0.0 (MAJOR, no compatibility) |
| 14 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | 2.0.1 (PATCH) |
| 15 | inbound-antispam-panel | 2.1.0 (was 1.10.0, reordered 2026-10-09) |
| 16 | delivery-log-storage | 2.2.0 |
| 17 | panel-notifications | 2.3.0 |
| 18 | from-header-binding | 2.4.0 |
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | 2.4.1 (PATCH) |
| 20 | audit-2026-10-12 stage 0: release hygiene | 2.4.2 (PATCH) |
| 21 | audit-2026-10-12 stages 1 and 2 | 2.5.0 |
| 22 | audit-2026-10-12 stage 2b: the log tailer | 2.5.1 |
| 23 | audit-2026-10-12 stage 2c: the sender check reads From: as OpenDKIM does | 2.5.2 |
| 24 | audit-2026-10-12 stage 3: persistence and restore | 2.5.3 |
| 25 | audit-2026-10-12 stage 4: panel behaviour | 2.5.4 |
| 26 | audit-2026-10-12 stage 5: image and Postfix hardening | 2.6.0 |
| 27 | port25-bare-newline | 2.6.1 (PATCH) |
| 28 | base-trixie | 2.7.0 |
| 29 | panel-dkim-signing | 2.8.0 |
Docs-only prep (checklists in plans, no product code) uses the same per-step commit rule but no version cut until the next product stage ships.
Technology stack and tools
| Component | Version / notes |
|---|---|
| Go | 1.26+ (go.mod); CGO_ENABLED=0 — pure Go, static linking |
| SQLite | modernc.org/sqlite (pure Go, no cgo) |
| Build | Makefile: primary targets vet, test, build, e2e (also all, clean) |
| Container | Docker + Compose v2 on the dev host and in CI |
| Image (build stage) | golang:1.26-trixie (bookworm until 2.7.0) — build/Dockerfile. The tag floats to the newest 1.26 patch release, and most standard-library security fixes (net/http, html/template, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (go get …@latest, go mod tidy), at least at each audit |
| Image (runtime) | debian:trixie-slim (bookworm until 2.7.0) + Postfix, supervisord, SASL, logrotate (OpenDKIM until 2.8.0; the panel signs since) |
| CI | GitHub Actions — .github/workflows/ |
| Image registry | ghcr.io/mixeme/selfpost |
Repository layout (brief; process details in architecture.md):
cmd/panel— HTTP panel + milters (sender check, DKIM signer, journal) + log-tailer + notifiercmd/selfpost-backup— backup CLI (docker exec … selfpost-backup)internal/— domain logic, store, web, healthbuild/— Dockerfile, supervisord, Postfix, entrypointdeploy/—docker-compose.yml, proxy examples,.env.exampletest/e2e/— separate Go module; container integration tests
External libraries
The project is AGPL-3.0 (LICENSE). Copyright holder and
third-party notices: NOTICE. The tree does not use per-file
SPDX-License-Identifier headers; AGPL-3.0 does not require them. New Go
dependencies must be permissive or GPL-family (see
.cursor/rules/agent-rules.mdc).
Main module (go.mod)
| Package | Version | Repository | License |
|---|---|---|---|
github.com/emersion/go-milter |
v0.4.1 | https://github.com/emersion/go-milter | BSD-2-Clause |
github.com/emersion/go-msgauth |
v0.7.0 (dkim, the signer) |
https://github.com/emersion/go-msgauth | MIT |
golang.org/x/crypto |
v0.58.0 | https://github.com/golang/crypto | BSD-3-Clause |
modernc.org/sqlite |
v1.60.1 | https://gitlab.com/cznic/sqlite (mirror: https://github.com/modernc-org/sqlite) | BSD-3-Clause |
Transitive dependencies — go mod graph / go.sum; all indirect packages in
the tree are AGPL-3.0-compatible.
Vendored front-end
| Asset | Version | Repository | License |
|---|---|---|---|
internal/web/view/static/htmx.min.js |
2.0.4 | https://github.com/bigskysoftware/htmx | 0BSD |
internal/web/view/static/ibm-plex-*.woff2 |
latin subset | https://github.com/IBM/plex | SIL OFL 1.1 (OFL.txt beside the files) |
internal/web/view/static/bulma.min.css |
1.0.4 | https://github.com/jgthms/bulma | MIT |
internal/web/view/static/tabler-icons.css |
3.49.0 (57-icon subset) | https://github.com/tabler/tabler-icons | MIT |
internal/web/view/static/tabler-icons.woff2 |
3.49.0 (57-icon subset) | https://github.com/tabler/tabler-icons | MIT |
E2e module (test/e2e/go.mod)
| Package | Version | Repository | License |
|---|---|---|---|
github.com/emersion/go-msgauth |
v0.6.8 | https://github.com/emersion/go-msgauth | MIT |
The test module is not part of the main go build graph and is not shipped in
the image. The e2e sink image also installs dkimpy (python3-dkim, Debian
trixie) as the verifier that is not the signer's library
(test/e2e/sink/Dockerfile).
Debian packages in the runtime image
Postfix, supervisord, sasl2-bin, logrotate, and others come
from Debian trixie repositories; licenses are in each package's copyright
file on https://packages.debian.org/trixie/.
The image also ships LICENSE, NOTICE, and the IBM
Plex OFL.txt under
/usr/share/doc/selfpost/. The panel serves the AGPL text at /license and
the OFL text at /static/OFL.txt.
Code that relies on another component's behaviour
The owner's rule (2026-10-14): what another program does is read in its
documentation or its source - never guessed. SelfPost reads Postfix's log
lines and reply codes, parses postqueue, postconf, supervisorctl and
db_dump output, talks the milter protocol through go-milter, signs with
go-msgauth over the headers Postfix hands a milter, relies on SQLite's locking, on logrotate's and Docker's
behaviour. Every block of code that does so carries a comment that says
what it relies on and where that is written, for the version the image runs
(the Debian trixie package, not upstream's head):
// Postfix 3.10.13, src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): the SASL login is compared with
// each owner in sender_login_maps by strcasecmp_utf8.
The Debian sources of the image's versions are fetched with apt-get source
inside a debian:trixie container (postfix, cyrus-sasl2, supervisor,
logrotate); dev/upstream/ (not in git) is where they are kept for
reading, and dev/upstream-bookworm/ holds the bookworm ones the citations
named before 2.7.0. opendkim and sendmail (for libmilter) are no longer
the image's since 2.8.0; they stay in dev/upstream/ only as what the
removed code and some historical comments cite (the signer's header list,
internal/milter/sign.go, names OpenDKIM's). Go modules are read in the
module cache at the version go.mod pins.
The image takes debian:trixie-slim and installs without pinning (pinned
Debian versions leave the mirrors), so a rebuild after a point or security
release runs packages the citations were not read against. The versions
read are these; the e2e stand compares them with dpkg-query in the image
it built (checkCitedPackageVersions), and a difference fails the run -
re-read what changed, update the citations, then this table:
| Package | Version read |
|---|---|
postfix |
3.10.13-0+deb13u1 |
libsasl2-2 |
2.1.28+dfsg1-9 |
sasl2-bin |
2.1.28+dfsg1-9 |
supervisor |
4.2.5-3 |
logrotate |
3.22.0-1 |
A document instead of source: its URL and section. Where neither answers the question, the comment says so and what was measured instead - "measured on the e2e stand, 2026-10-14: ..." - and a measurement is never written as if it were read. A test fixture copied from a real log says where and when it was seen; one composed by hand says that it was composed. The heuristics review of 2026-10-14 (audit-2026-10-12, stage 2c) brought the code that existed then up to this rule.
Building binaries and the image
Local binaries
Requires Go 1.26+ and CGO_ENABLED=0.
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.2.3
Or directly:
go build -trimpath -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel
The version is stamped into both binaries via -ldflags and must match the
Docker image tag — restore checks backup version compatibility.
Docker image
From the repository root:
docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
The Dockerfile has a build stage (go vet, go build with VERSION) and a
runtime stage (Debian + mail stack). Runtime config and scripts use COPY --chmod so file modes in the image do not depend on how the build context was
synced (e.g. a Windows checkout widening permissions on logrotate-mail.conf).
See architecture.md § Image and processes.
Commits and release build
Commit on every meaningful step (a working sub-feature, a green build, end of a
phase) — not every file save, and not only at phase end. Minimum: one commit per
closed phase, plus intermediate commits for coherent sub-steps. Branch main
unless a separate branch is requested. Push / PR only on explicit request.
Commit messages end with
Co-Authored-By: Claude <model> <noreply@anthropic.com> for the model that did
the step (e.g. Claude Sonnet 4.6).
Every such step also updates CHANGELOG.md under
[Unreleased] (Keep a Changelog). On an explicit version cut, rename
[Unreleased] to [X.Y.Z] - date and open a fresh empty [Unreleased]. Image
tag / push only on explicit request (see release.yml).
Release image
The release image is published only for a SemVer version X.Y.Z: a
published GitHub Release whose tag is vX.Y.Z, or a workflow_dispatch
that supplies that version. Pushing a git tag alone does not publish. Ordinary
commits, and a dispatch from main without a version input, do not publish.
The version is the single source that drives the image tag and -ldflags in
the binaries so they cannot drift apart. The e2e gate rebuilds the image it
then pushes, so release.yml hands it the version as SELFPOST_E2E_VERSION;
the suite's image_stamped_with_version and a check right before the push
both compare panel -version and selfpost-backup -version with it. (Until
2.4.2 the rebuild stamped every published image e2e; those images stay as
they are.)
Steps (on explicit request):
- Close
[Unreleased]in CHANGELOG.md and bump the pinned tag in deploy/docker-compose.yml (and any local-trial image references) in the same release commit. While no image of the current line is published, README, guide § Fixed image tag and the compose file's header say so and show the local build under the pinned name; bump the version there too. The first publication of the line removes those notes. - Create and push git tag
vX.Y.Zon that commit. - Publish the GitHub Release for
vX.Y.Z(not a draft). - Workflow release.yml builds, e2e-gates,
and publishes
ghcr.io/mixeme/selfpost:X.Y.Z(checks out tagvX.Y.Z).
GitHub Release vs GHCR. The public Releases page lists only published releases. A draft is visible to maintainers only — it looks like “no releases” to everyone else. CI does not create or publish the GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub (or re-pushing tags while cleaning the registry) converts a published release back into a draft — that matches “I published three times and it keeps disappearing”. After publish, leave the tag on GitHub; clean up only unwanted GHCR package versions, not the git tag.
Push workflow and source changes to github.com/mixeme/selfpost before publishing — Actions reads that repo, not Gitea.
Gitea → GitHub tag mirror. If every tag push from Gitea is mirrored to GitHub, two things follow:
-
GitHub Release tags must not be deleted on GitHub. Many mirror setups prune remote tags that are absent on Gitea (or re-push with
--force/--prune). Deletingv1.0.0/v1.3.0on GitHub converts a published Release back to draft. Mirror branches and new tags forward; do not delete release tags on the GitHub side. GHCR cleanup is package versions in the UI — notgit push github --deleteand not tag prune on the mirror. -
Tag push runs the workflow file at that tag's commit, not
main.v1.0.0still points at a commit whoserelease.ymlhason: push: tagsand no per-arch GHCR cleanup — every mirror (re)push of that tag can republish1.0.0-amd64/1.0.0-arm64. Tags fromv1.3.0onward only runrelease.ymlon Publish release (release: published), so mirroring those tags alone does not start the image build.Safe mirror: push tags to GitHub without deleting existing ones; keep release tags on Gitea; publish the GitHub Release on github.com after the mirror has the tag.
Ordinary commits do not publish an image. The compose pin and the git tag
must match (1.0.0 / v1.0.0 for the first published release). Intermediate
CHANGELOG sections (0.2.0…0.6.0) record development history before that cut.
Phase closure
Before /clear at the end of a finished step:
- Update roadmap.md (and the active plan checklist, if any): what changed, what is next.
- Check the applicable «Done when…» criteria.
- Append CHANGELOG.md under
[Unreleased]. - Make the final commit for the step (when the user asks for a commit).
Testing
Static analysis and unit tests
Main module (go test ./...); e2e is a separate module — see below.
make vet # go vet ./...
make test # go test ./...
Or directly:
gofmt -l . # in CI — fails on drift
go vet ./...
go test ./...
Env documentation regression
go test ./cmd/panel -run 'TestEnvironment' compares what the code reads with
what the documents name (cmd/panel/envdoc_test.go).
Read: the envDefault / envInt / envBool / envSeconds / envDuration /
os.Getenv / os.LookupEnv calls in cmd/panel and internal (tests aside),
and ${VAR:-…} in build/*.sh. Named: guide.md § Environment
variables (the table and the paragraph under it) and the internal list in
architecture.md § Configuration. A variable read and named
nowhere fails the first test; one named and read nowhere fails the second.
Panel pages: the component kit and its guards
Every panel page is built from the component kit in
internal/web/view: partials in
templates/components.html, their typed input in components.go, one stylesheet
static/panel.css, one layout layout.html. A page template calls partials with
a struct from a view.New… constructor and writes no component markup of its
own (architecture.md § Panel HTTP surface).
The guard tests (internal/web/guard_*_test.go, internal/web/view/guard_*_test.go)
run in go test ./... and read their rules from the accepted mockups under
docs/assets/panel-redesign/panel/: which
classes and icons exist, what panel.css may contain, what component skeleton
each page has, which routes exist. A failing guard means the page is wrong, not
the guard.
Changing the design is mockup first: edit the fragment under panel/src/, run
build.py and check.py --write, commit as design: …, get it accepted, and
only then touch internal/web. CI enforces the order with design-first
(.github/scripts/design-first.sh): a commit
that changes the mockups, a guard or the script touches nothing else under
internal/web/, and a template that returns from git history is refused.
plans/panel-redesign.md § The contract and
§ Enforcement have the full rules.
End-to-end (container suite)
Separate Go module test/e2e/; not included in the main module's
go test ./....
make e2e
# same as: cd test/e2e && go test -v -timeout 20m ./...
Stack: deploy/docker-compose.yml +
test/e2e/compose.override.yml — same
cap_drop/cap_add/no-new-privileges as production. Override: high ports
(20465/20587/20080), test hostname, self-signed TLS,
PANEL_COOKIE_SECURE=false, isolated compose project, and the image's
VERSION from SELFPOST_E2E_VERSION (unset locally: e2e). Mail is hermetic:
CoreDNS (fake zone) + Postfix smtp-sink as sink-MX; DKIM TXT is scraped from
the panel and published into the zone — the test verifies the records an
operator would actually use.
Coverage (summary): bootstrap → SMTP AUTH → delivery → DKIM verify →
send-log queued → sent; negatives (no AUTH, relay, sender/login mismatch,
L1/L2 limits, the panel down or hung mid-message (Postfix defers the mail
rather than letting it through: panel_down_defers_mail,
panel_hung_mid_message_is_counted), bad SELFPOST_HOSTNAME, session
survives docker restart); startup checks that supervisord actually brought up
the panel, Postfix, cert-reload and logrotate (checkSupervisorProcesses),
that /data/dkim is the panel's alone, directories 0700 and files 0600
(checkDKIMTree), and that the image's Debian packages are the cited ones
(checkCitedPackageVersions), plus logrotate config-mode and forced-rotation
checks (checkLogrotateConfigMode, checkLogrotateRotation —
test/e2e/logrotate_check.go).
DKIM has an oracle that is not the signer's library: on every positive send
and on a notification, checkPanelSignature
(test/e2e/mail_helpers.go) requires exactly
one signature, the panel's (relaxed/simple, rsa-sha256, From signed and
over-signed), and has dkimpy (dkimverify in the sink) verify it against the
stand's CoreDNS; go-msgauth's verifier (verifyDKIM) checks the published
record as a receiver would. signature_over_awkward_headers
(test/e2e/dkim_shapes_test.go) sends the
header shapes Postfix hands a milter in its own way - folded, tab-led, no
space after the colon, two To: - and an 8-bit body, and dkimpy verifies
what was delivered. TestStartsOnA27Data
(test/e2e/upgrade_test.go) starts the image on
a /data a 2.7.x image left: the key under /data/opendkim is copied to
/data/dkim unchanged and the selector table names it, the old tree becomes
the panel's alone, and a queue owned by other uids is handed to Postfix's
users by name.
Waits poll with a timeout (waitFor); a fixed sleep is the exception and says
why where it stands (sender_check_test.go: a downtime worth a second or two,
and 35 s to show a row is not written again).
Requires Docker + Compose v2 on the machine running the suite.
CI
Workflows in .github/workflows/. What each job runs — § Testing above.
test.yml — every push and PR to main
design-first (needs the full history) → gofmt -l → go vet ./... →
go test ./... (main module, no e2e; includes the panel guard tests, see
§ Panel pages) → the e2e
module compiled, not run (go vet ./... and go test -c -o /dev/null . in
test/e2e).
release.yml — published GitHub Release, or workflow_dispatch with SemVer
Publishing a GitHub Release runs release.yml directly (release: published,
same pattern as gosentry / imap-scrub). You can also run it manually via
workflow_dispatch with an explicit X.Y.Z input. A bare git tag push does not
run the workflow. The build always checks out vX.Y.Z, not main HEAD.
prepare takes the version from github.event.release.tag_name on a release
event, or from the workflow_dispatch version input. A dispatch whose input
is missing or not X.Y.Z fails in prepare.
release: published
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
→ docker build --load (VERSION from prepare)
→ e2e (test/e2e; rebuilds selfpost:e2e with SELFPOST_E2E_VERSION=X.Y.Z)
→ check panel / selfpost-backup -version = X.Y.Z
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge
→ docker buildx imagetools create → unified manifest X.Y.Z
→ GitHub Packages API → drop untagged versions no tagged manifest references
Native per-arch matrix (no QEMU): running the full Postfix stack under
emulation for e2e is impractical. E2e first, then push — the registry receives
the bytes that passed the gate. Each release leaves three tags in GHCR:
X.Y.Z (the multi-arch manifest operators pin) and X.Y.Z-amd64 /
X.Y.Z-arm64 (the images it points at). The per-arch tags must stay — GHCR
cannot drop a tag without deleting the image, and deleting it breaks pulls of
X.Y.Z. The last merge step removes only orphans: untagged versions left
behind when a version is published again.
A failed e2e blocks image publication.
Documentation
History of deleted plans lives in git and CHANGELOG.md.
There is no docs/archive/ directory.
Documentation map
| Home | File |
|---|---|
| Operator install / quick start | README.md |
| Operator guide | guide.md |
| Product boundaries | product.md |
| As-built design | architecture.md |
| Development process (this file) | development.md |
| Security requirements and accepted risks | security.md |
| SQLite schema and migrations | schema-migrations.md |
| Roadmap (1.x+) | roadmap.md |
| Active design plans | plans/ |
| Release history | CHANGELOG.md |
User-facing deliverables
| Artefact | Role |
|---|---|
| README.md | Overview, requirements, quick start, docs index, reference deploy, licence |
| guide.md | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
| SECURITY.md | Private reporting channel, supported versions, scope |
| LICENSE | AGPL-3.0 full text |
| NOTICE | Copyright holder and third-party attributions |
| deploy/docker-compose.yml + proxies | Apache + nginx/Caddy/Traefik under deploy/ |
| deploy/.env.example | Public env template; full reference in guide.md |
| CHANGELOG.md | Keep a Changelog |
Out of scope for v1.x: CONTRIBUTING.md, man pages, a separate docs site
(candidates in roadmap.md).
Maintaining documentation
- Step rule: a new or renamed env key, panel route, or observable mail-path
behaviour ships together with guide.md /
.env.exampleand a CHANGELOG entry (see § Commits and release build). - Env regression: cmd/panel/envdoc_test.go fails on a variable the code or a build script reads that no document names, and on one a document names that nothing reads.
- New gaps go into roadmap.md (or the active plan file), not silent drive-by edits.
- Schema changes: update schema-migrations.md in the
same change as a new
internal/store/migrations/*.sqlfile.
Verifying docs against code
Every claim in the docs has a source of truth in the tree; verify from code to prose.
| Claim class | Source of truth |
|---|---|
| Env keys and defaults | loadConfig — cmd/panel/main.go; ${VAR:-…} in build/ |
| Mail path | build/postfix-config.sh |
| Panel routes | internal/web/web.go |
| Backup / restore, domain export | internal/backup/, cmd/selfpost-backup/ |
| Sessions | internal/store/sessions.go, internal/web/auth/session.go |
| Log rotation, reload | build/logrotate-mail.conf, build/logrotate-loop.sh, build/postfix-cert-reload.sh |
| Deploy | deploy/docker-compose.yml, build/Dockerfile |
| Operator checklist | § User-facing deliverables; detail — guide.md |
| Product / out of scope | product.md |
| As-built | architecture.md |
| Mandatory security | security.md |
Order: list what the code actually does → find it in guide.md /
architecture.md. Before every tag, a short pass over this table — not a full
prose rewrite.