Contributing to acme-proxy
Thank you for your interest in contributing to acme-proxy! Whether you’re
fixing a bug, adding a new feature, or improving documentation, your help is
welcome.
Development environment
To start developing, ensure you have the following installed:
- Rust (latest stable version)
sqlite3(for database inspection, thoughsqlxhandles migrations)- mdBook (if you want to build this documentation locally)
Initial setup
Clone the repository and build the project:
git clone https://github.com/acme-proxy/acme-proxy.git
cd acme-proxy
cargo build
Testing
The suite is what holds RFC 8555 compliance in place, and CI enforces a coverage floor, so a change that adds a branch generally has to add a test for it.
Before submitting a pull request, run the full suite with nextest:
cargo nextest run --workspace
--workspace is not optional. The repository root is both the acme-proxy
package and the root of a workspace of library crates under crates/, and a
bare cargo command at such a root acts on the root package alone — the unit
tests of every library crate would simply not run.
Use
cargo nextest run --workspace, notcargo test. This is a requirement, not a preference: several tests execute a script file they have just written, and undercargo test— which runs tests as threads of a single process — another thread’sCommand::spawncan fork while the file’s write descriptor is still open, failing withETXTBSYroughly one run in three. nextest’s process-per-test isolation removes the race entirely. See Testing & Coverage.
What CI will check
Your pull request has to pass all of these:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo llvm-cov nextest --workspace --summary-only --fail-under-lines 97
cargo test --workspace --doc # llvm-cov skips doc-tests
cargo deny check # supply-chain audit, against deny.toml
RUSTDOCFLAGS="-D warnings -A rustdoc::private_intra_doc_links" \
cargo doc --workspace --no-deps --all-features # every intra-doc link
mdbook build doc/ && python3 doc/lint.py # this book
cargo test --doc compiles the doc examples but not the intra-doc links, of
which the workspace has a great many; cargo doc -D warnings is what catches a
link a rename broke. Private intra-doc links are allowed on purpose: the library
exists for the binary and the tests, and a public item explaining itself by
naming the private thing it delegates to is the good outcome.
doc/lint.py holds the book to its own conventions: 80-column prose, no
numbered headings, every fence tagged, every relative link and anchor
resolving, every ADR listed, and no configuration key documented in two
files — two copies of a default drift silently.
Four more jobs check what the ones above cannot:
msrvreadsrust-versionout ofCargo.tomland runscargo check --locked --workspace --all-targets --all-featureson exactly that toolchain, so the minimum stated there is one CI has verified.hsmruns clippy and the suite with--features acme-proxy-signer/hsmagainst SoftHSM2.--all-targetsenables no features, so without this job the PKCS#11 code would be neither linted nor tested; it is not folded into the coverage job, whose floor a feature-gated file sits outside of.postgresruns the wholeacme-proxy-storesuite,tests/postgres.rs,rolesandreloadagainst a real PostgreSQL server, withACME_PROXY_REQUIRE_POSTGRES=1so a skipped test is a failure. It is separate from the coverage job forhsm’s reason.e2eruns nightly, not on a push: a subset of the container lab intests/e2e/, with real certbot, acme.sh and lego clients.
The sbom job additionally regenerates sbom.cdx.json and fails if it differs
from the commit — see Changing dependencies.
Note the coverage floor is enforced, so new code generally needs new tests.
cargo test --doc is the only thing that compiles the startup example in
src/lib.rs.
Writing tests
- Unit Tests: Keep them close to the code (in the same file, in a
mod tests). - Integration Tests: Located in the
tests/directory. These tests spin up a full in-memory axum router and SQLite database to test the entire ACME flow.
See the Testing & Coverage page for more details.
Code style
- Format your code using
cargo fmt. - Ensure all lints pass by running
cargo clippy --workspace --all-targets -- -D warnings. - Document public APIs using rustdoc comments (
///). - Comments, doc comments and error-message strings are written in English, as are identifiers and log messages.
- Every
tracingcall carriesevent = "<subsystem>_<object>_<outcome>"as its first field, as a string literal rather than a computed value, so the name stays greppable. Several are asserted by the end-to-end suite — grep before renaming one. - The crate is edition 2024; see
rust-versioninCargo.tomlfor the minimum toolchain.
Changing the database schema
Both migration directories are append-only: crates/store/migrations/
(SQLite, since 0.1.0) and crates/store/migrations-postgres/ (PostgreSQL). Add
a migration to each; never edit a committed one:
sqlx migrate add --source crates/store/migrations add_widget_table
sqlx migrate add --source crates/store/migrations-postgres add_widget_table
sqlx tracks each migration by a checksum, so editing a file that has already
run turns every existing deployment into a startup failure. One build-system
trap while you work: sqlx::migrate!() embeds the set at compile time and
adding or removing a file under either directory does not on its own
invalidate the build, so a test can be run against the previous set — touch crates/store/src/db.rs after changing the directory. This reverses the rule
that held before the first release, when the server had never been deployed and
a schema change meant editing the migration and running rm -f sqlite.db*.
Three consequences:
- A new column is a new file, even when it plainly belongs to an existing
table.
ALTER TABLE ADD COLUMNis cheap; putting it in the originalCREATE TABLEis what breaks. Name it incrates/store/src/transfer.rs’s manifest too, oracme-proxy transferdrops it;the_manifest_names_every_columnrefuses a manifest that has drifted. - A new
CHECK,UNIQUEor foreign key needs a table rebuild in the SQLite set, because SQLite cannot add one to an existing table; PostgreSQL’sALTER TABLE … ADD CONSTRAINTneeds none. Write the rebuild in the new migration, and remember the two things a rebuild loses silently: anINSERT … SELECTdrops any column you forget to name, andDROP TABLEtakes the table’s indexes with it — including ones declared in an earlier migration, which will not run again to put them back. - A wrong declared width is a rebuild too. SQLite gives
VARCHAR(n)TEXT affinity and enforces no length, so a width that no longer matches its data costs nothing at runtime and is wrong everywhere else — in what.schematells an operator, and in any port to a dialect that does check.20260826120000_declared_widths_for_random_tokens.sqlis the worked example. Where the width follows a constant insrc/, pin the two together with a test; that file’sVARCHAR(43)isTOKEN_BYTESand nothing else, so a change to the constant has to reach the schema.
Adding a configuration key
A key is a field on one of the section structs under
crates/core/src/config/types/, with a #[serde(default)] that makes the whole
section optional. Beyond the field itself, a new key owes:
- Documentation in exactly one book page, as a
### Referenceentry naming its environment variable, plus an entry inconfig.toml.example(which a test deserializes, so it cannot rot into invalid TOML).doc/lint.pyrefuses a key documented in two pages. - A decision about scope. A section listed in
PROFILE_SECTIONSis per-profile and inherited key by key (see Profiles); one describing the process —[jobs],[audit],[metrics],[proxy],[admin]— is not. - A decision about reload. A reload rebuilds everything from the new
configuration, so a key reloads unless something snapshots it at startup.
Only
database.urlis refused onSIGHUP(FROZENincrates/server/src/reload.rs); a new key joins it only with a reason.
A list-valued key has one more obligation, and one thing to know:
#[serde(deserialize_with = "string_list")]on the field. An environment variable can only carry a string, and this is what splitsa,binto a list, at any depth: inside a profile or inside a named table ([filter.check.<name>],[notify.webhook.<name>], …) alike. It also reads a variable set to the empty string (a${VAR:-}shell default) as[]. Without it the key loads from a file and fails from the environment;every_list_field_reads_a_comma_separated_stringrefuses the omission.- A value containing a literal comma, such as a regex with
{2,3}, can only be set from the file, since the comma is the separator.
The environment source pins prefix_separator("_"). Without it, config
reuses the nested separator __ after the prefix and silently ignores every
ACME_PROXY_* variable.
Changing a configuration key
The schema is the only frozen surface. Before 1.0.0, renaming or removing a configuration key is a normal change rather than one to design around — that is what keeps the code free of a compatibility layer for every shape a section has ever had. What such a change owes:
- An entry in the changelog under the release’s
### Breakingheading, naming the old spelling and the new one. See Compatibility. - A startup error naming the replacement, where practical, so an unmigrated
configuration stops the server instead of coming up looking configured and
doing nothing.
crates/policy/src/filter/build.rs’srefuse_removed_keysand thesigner.backend = "acme_proxy"arm incrates/signer/src/lib.rsare the worked examples. A key must still parse to be refused by name, which is why the removed[filter]fields survive incrates/core/src/config/types/filter.rs; a field that is gone fails as an opaque serde error instead. - No alias, no dual syntax, no legacy lowering. Delete the old shape. The refusals themselves are one-line diagnostics and go away at 1.0.0.
Changing dependencies
sbom.cdx.json at the repository root is a committed CycloneDX
1.5 inventory of the dependency closure that ships in
the binary — the artifact ASVS 5.0 V15.1.2 asks for, alongside the cargo deny
gate. It is scoped --all-features --target all, so the hsm/cryptoki path
and every platform-gated crate are covered; dev-dependencies are excluded, since
they cannot reach a released build.
Regenerate it after any change to Cargo.toml or Cargo.lock, and when cutting
a release (it records the crate version). The sbom CI job runs the same recipe
and fails on any difference:
export SOURCE_DATE_EPOCH=0
cargo metadata --locked --format-version 1 >/dev/null
cargo cyclonedx --all-features --target all --spec-version 1.5 \
--format json --override-filename sbom.cdx -q
jq --arg from "path+file://$PWD" --arg to "path+file:///acme-proxy" \
'walk(if type == "string" then ((if startswith($from) then $to + .[($from | length):] else . end) | gsub("path\\+file:///acme-proxy#acme-proxy@"; "path+file:///acme-proxy#")) else . end) | del(.metadata.timestamp)' \
sbom.cdx.json > sbom.cdx.json.tmp
mv sbom.cdx.json.tmp sbom.cdx.json
rm -f crates/*/sbom.cdx.json
The tool writes one document per workspace member. The committed one is the binary’s, whose closure already names every library crate, so the per-member copies are deleted rather than committed.
cargo install cargo-cyclonedx@0.5.9 --locked provides the generator; keep the
version in step with the pin in .github/workflows/ci.yml, since it is written
into the document. SOURCE_DATE_EPOCH makes the output reproducible (it also
suppresses the otherwise-random serialNumber); the jq pass drops the
wall-clock timestamp and rewrites the bom-ref values the tool derives from
the checkout path — both the absolute directory it embeds and the name@
segment it drops when that directory’s basename happens to equal the crate
name, so the file is identical whether it was regenerated in a worktree named
acme-proxy or anything else.
Cutting a release
main is the trunk: every pull request targets it, and a minor release is a
tag on it. A patch release comes from a release/X.Y branch, cut from the
X.Y.0 tag when the first fix needs to ship.
ADR 0013 argues the model.
Every crate of the workspace is published to crates.io together, at the
binary’s version: the library crates are internal, with no semver promise of
their own, and exist on crates.io only so cargo install acme-proxy can build.
A minor release
-
On
main, bumpversionin[workspace.package]of the rootCargo.tomland every=x.y.zpin on anacme-proxy-*crate in[workspace.dependencies], together. The exact pins are what keep the crates in step. -
Regenerate
sbom.cdx.json(above); it records the version. -
Check the whole set packages and builds from its own archives, then publish it, in dependency order:
cargo publish --workspace --dry-run cargo publish --workspacePublishing a workspace in one command needs cargo 1.90 or later, below the minimum supported Rust version.
-
Once that commit is on
mainand its CI run is green, push the bare version as a tag:git tag -a 0.6.0 -m 0.6.0 && git push origin 0.6.0The tag triggers
.github/workflows/release.yml, which builds the image on an amd64 and an arm64 runner and publishesghcr.io/acme-proxy/acme-proxyas0.6.0,0.6andlatest, with a provenance attestation. ADR 0012 explains its shape. Itsguardjob stops the release, with nothing published, in four cases:- The tag is not the workspace version, or a crate pin is stale. The tag is most likely mistyped: delete it and push the right one. If step 1 was incomplete, fix the manifest first.
- The tag is off its line.
X.Y.0must be onmain, andX.Y.Zonrelease/X.Y. Move the tag. - There is no CI run on that branch for the commit. The tag is on a commit that was never pushed to the branch. Move the tag.
- CI is pending or failed. Wait for it, or fix it, then use “Re-run all jobs” on the release run for the same tag.
To rehearse the workflow without publishing, run it from a branch under “Run workflow”. It builds both architectures and pushes nothing.
The first time the package is published, it is private, even though the repository is public. In the package’s settings, make it inherit access from the repository, then check that
podman pullworks with no credentials.
A patch release
-
If
release/X.Ydoes not exist yet, cut it from the minor’s tag and push it. The branch ruleset lets a new branch be created without a pull request:git switch -c release/0.6 0.6.0 && git push origin release/0.6 -
Backport every fix it ships, as below.
-
On a topic branch off
release/X.Y, bump the version and the pins, regenerate the SBOM, and give the changelog its## [X.Y.Z]section, holding the backported entries. Merge it intorelease/X.Yby pull request. -
Publish the crates from that commit, as in step 3 above.
-
Once its CI run on
release/X.Yis green, tag it:git tag -a 0.6.1 -m 0.6.1on the branch’s head, then push the tag. The image is published as0.6.1and0.6, and aslatestonly when no higher release exists. -
Cherry-pick the changelog section onto
main, so the trunk’s changelog lists every release, and take the fixed entries out of[Unreleased]there.
Backporting a fix
A fix is merged on main first, and reaches a release branch as a
cherry-pick, never the other way around:
git switch -c backport/0.6/fix-name origin/release/0.6
git cherry-pick -x <commit on main>
Open the pull request against release/0.6. The -x trailer names the commit
on main the fix came from. When the cherry-pick conflicts, resolve it on the
topic branch and say in the pull request what differs from the original.
Trying the next release
Every merge to main publishes ghcr.io/acme-proxy/acme-proxy:edge, once
the whole of ci.yml has passed on it. The image job at the end of ci.yml
calls release.yml for that.
Submitting a pull request
- Fork the repository and create your branch from
main, fixes included. A fix is backported to a release branch after it merges (see above). - Write clear, descriptive commit messages.
- If you’ve added code that should be tested, add tests.
- If you’ve changed APIs, update the documentation in this
mdBook. - Open a PR, describing the problem you’re solving and how you fixed it.
Architecture guidelines
If you are proposing a large feature (like a new Signer or Filter), please review the Architecture & Design documentation first. It’s often best to open an Issue to discuss the design before writing extensive code.
Open work
Planned and deferred work lives in the
issue tracker, one issue per
item, labelled by subsystem (server, store, webadmin, signer, ipam,
notify). Several of them record why something was investigated and not
built, which is worth reading before proposing it again.