Deployment
While acme-proxy can be run in a container, many organizations prefer running
infrastructure components directly on standard Linux VMs using systemd.
systemd service setup
Below is an example systemd service file that runs acme-proxy securely.
-
Create a dedicated user:
sudo useradd -r -s /bin/false acme-proxy -
Prepare directories:
sudo mkdir -p /etc/acme-proxy sudo mkdir -p /var/lib/acme-proxy sudo chown acme-proxy:acme-proxy /var/lib/acme-proxy -
Create the service file: Create
/etc/systemd/system/acme-proxy.service:[Unit] Description=ACME Proxy Server After=network.target [Service] Type=simple User=acme-proxy Group=acme-proxy ExecStart=/usr/local/bin/acme-proxy serve ExecReload=/bin/kill -HUP $MAINPID WorkingDirectory=/var/lib/acme-proxy # Configuration. The extension may be omitted, in which case the format # is inferred. Environment="ACME_PROXY_CONFIG=/etc/acme-proxy/config.toml" Environment="ACME_PROXY_DATABASE__URL=sqlite:///var/lib/acme-proxy/acme.db" # Security / Sandboxing ProtectSystem=strict ReadWritePaths=/var/lib/acme-proxy ProtectHome=true PrivateTmp=true NoNewPrivileges=true Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
ProtectSystem=strict makes the whole filesystem read-only except
ReadWritePaths, so everything the server writes must land in
/var/lib/acme-proxy. With WorkingDirectory set there, the defaults already
do: signer.local_ca.cert_path (ca.pem), key_path (ca.key), crl_path
(ca.crl) and the lock beside it are all resolved relative to the working
directory, as are server.tls.cert_path / key_path if you enable TLS.
If you set any of them to an absolute path, add that path to ReadWritePaths
too.
acme-proxyshuts down gracefully onSIGTERM(and on Ctrl+C when run in a terminal), sosystemctl restartandsystemctl stoplet in-flight requests finish rather than cutting them off. Both listeners stop together. It also reloads its configuration onSIGHUPwithout restarting, which is what theExecReloadline above wires up — see Reloading the Configuration for what a reload may change and what it refuses. One case still deserves a quiet period: a request that waits — acustomscript’scrlorrenewal_infohook, or a relay revocation waiting on its job — can take up toserver.request_timeout_ms. If systemd’sTimeoutStopSec(90 s by default) is shorter than that, systemd sendsSIGKILLfirst and the graceful path is skipped — raise it, or lower the request timeout. Challenge validation and issuance are not among these: they run in the job queue, and a job left unfinished by a restart is reclaimed by lease expiry.
- Enable and start the service:
sudo systemctl daemon-reload sudo systemctl enable --now acme-proxy
Where each socket belongs
acme-proxy opens two listeners when the web admin is enabled, and they
belong on different sides of your boundary. The ACME listener answers
unauthenticated clients by design; the admin listener has no filter chain and no
admission control, and its only access controls are the bind address, TLS and
the session.
graph TD
subgraph internal["Internal network"]
CLIENTS["ACME clients<br/>certbot, acme.sh, Traefik"]
OPS["Operator workstation"]
end
subgraph host["The acme-proxy host"]
RP["Reverse proxy (optional)<br/>sets X-Forwarded-For"]
ACME[":3000 — ACME listener<br/>filters, admission, nonces"]
ADMIN[":3001 — admin listener<br/>loopback by default"]
DB[("sqlite.db + WAL")]
CAKEY[["ca.key — 0600, or a PKCS#11 token"]]
end
CLIENTS --> RP --> ACME
OPS -.->|"SSH tunnel or VPN,<br/>NOT an open port"| ADMIN
ACME --> DB
ADMIN --> DB
ACME --> CAKEY
ACME -->|"challenge validation:<br/>back to the client, :80 / :443 / DNS"| CLIENTS
ACME -->|"upstream ACME, DNS updates, SMTP"| OUT(["Egress"])
Two edges are the ones people get wrong:
- The dotted one. If the admin listener is reachable from anywhere but
loopback, startup refuses unless
admin.tls.enabledis on — and even then, a tunnel is the better answer. See below. - The validation edge points back at the client. With
challenge.bypass = false, the server opens connections to the machines asking for certificates. A firewall that only permits inbound traffic leaves orders sitting atpending.
Reverse proxy (optional)
acme-proxy acts as an HTTP server, typically binding to port 3000. You can
bind it directly to 80 (requires CAP_NET_BIND_SERVICE) or place it behind a
reverse proxy like Nginx or Traefik, which can provide TLS termination for the
ACME API itself.
Two things to get right when proxying:
- Set
server.base_urlto the public URL. It is what the directory advertises and what every signed request is checked against (RFC 8555 §6.4), so a mismatch rejects every client. It is never derived from the request. - If you want IP-based filters to see the real client rather than the proxy, set
filter.trusted_proxiesto the proxy’s addresses and, if it is notx-forwarded-for,filter.forwarded_header. Note these are[filter]keys, not[server]keys.
Alternatively, skip the reverse proxy and let acme-proxy terminate TLS itself
— see TLS Termination.
Exposing the web admin (or rather, not)
The Web Admin is a second listener and is off
by default. When you turn it on, it binds 127.0.0.1:3001 and stays there
unless you say otherwise.
The recommended way to reach it is an SSH tunnel, which needs no configuration change and no second certificate:
$ ssh -N -L 3001:127.0.0.1:3001 ca.example.com
Then open http://localhost:3001. admin.base_url stays at its default,
because from the browser’s point of view the panel really is on localhost.
If you must bind it to a real interface, TLS is mandatory — startup refuses
a non-loopback bind while admin.tls.enabled is false, because the session
cookie is sent Secure and a browser silently declines to store one over plain
HTTP anywhere but localhost:
[admin]
enabled = true
bind_address = "0.0.0.0:3001"
base_url = "https://admin.example.com:3001"
[admin.tls]
enabled = true
Two things this listener does not have, deliberately: admission control and
a filter chain. Access control here is the bind address, TLS, and the session.
Note also that it does not honour X-Forwarded-For — behind a reverse proxy
the sign-in rate limiter counts the proxy, which is one more reason to prefer
the tunnel.
Under systemd, nothing extra is needed: the panel shares the process, the unit and the database. Bootstrap the first operator once, before or after enabling it:
$ printf '%s' "$PASSWORD" | acme-proxy admin user create alice
Container deployments (Docker / Podman)
For containerized environments, you can run acme-proxy using Docker Compose or
Podman.
Each release is published as ghcr.io/acme-proxy/acme-proxy:<version>, for
linux/amd64 and linux/arm64, and the examples below pin one.
Installation covers why to pin,
building the image yourself from the Containerfile, and verifying its
provenance:
podman pull ghcr.io/acme-proxy/acme-proxy:0.6.0
The image’s working directory is /data and its entrypoint is the binary
itself, so /data is where the database, the CA key material and the CRL land
unless you override their paths.
The image runs as a non-root user (acme-proxy, uid/gid 1000), so the
directory you mount at /data must be writable by that uid. How you arrange
that depends on the runtime:
- Rootless Podman — add
Uto the mount flags (-v ./data:/data:U, or:Z,Uon SELinux systems). Podman then chowns the volume’s contents to the user the container runs as. Alternatives:podman unshare chown -R 1000:1000 ./databeforehand, or use a named volume (-v acme-proxy-data:/data), which Podman initializes with the right owner. - Docker / Docker Compose — create the directory owned by uid
1000before the first run:mkdir -p ./data && sudo chown 1000:1000 ./data. Or override the uid to your own (--user "$(id -u):$(id -g)", or a Composeuser:line) and own./datayourself — the server only needs to read and write that one directory.
Docker Compose
Create a docker-compose.yml file:
services:
acme-proxy:
image: ghcr.io/acme-proxy/acme-proxy:0.6.0
container_name: acme-proxy
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/data
environment:
- ACME_PROXY_PROFILES__DEFAULT__ENABLED=true
- ACME_PROXY_DATABASE__URL=sqlite:///data/acme.db
- RUST_LOG=acme_proxy=info
Create the data directory with the right owner before the first run:
mkdir -p ./data && sudo chown 1000:1000 ./data
Run the stack using:
docker compose up -d
ACME_PROXY_PROFILES__DEFAULT__ENABLED=true is what defines the profile when
there is no configuration file — the server serves nothing without at least one.
For anything beyond a single default profile, mount a config.toml into /data
instead.
Podman (rootless)
Under rootless Podman you can run the container directly. The U flag chowns
the mounted directory to the non-root user the container runs as; add Z as
well on SELinux-enabled systems (RHEL/Fedora) for the mount label.
podman run -d --name acme-proxy \
-p 3000:3000 \
-v ./data:/data:U \
-e ACME_PROXY_PROFILES__DEFAULT__ENABLED=true \
-e ACME_PROXY_DATABASE__URL=sqlite:///data/acme.db \
ghcr.io/acme-proxy/acme-proxy:0.6.0
Running the roles as separate processes
acme-proxy serve runs three jobs in one process: serving ACME, serving the web
admin, and draining the job queue. --role splits them across processes of the
same binary, reading the same configuration.
| Role | Does | Holds |
|---|---|---|
acme | Serves ACME to certificate clients | The ACME listener |
admin | Serves /ui and /api | The admin listener |
worker | Drains the job queue; signs and revokes; owns the schema and the first-run material | The CA key or token, a relay’s upstream account; no listener |
All-in-one is still the default and nothing about it changes: acme-proxy serve with no --role behaves exactly as it always did. The split is worth
doing when you want privilege separation — the process parsing untrusted JWS and
CSRs from the internet is then not the one holding operator sessions, and
neither is the one making outbound connections to client-chosen hosts. Each can
run under its own uid and its own systemd sandbox.
Only the worker holds signing material. finalize queues the signing and
answers processing, a revocation is a database row or a queued job, and the
CA’s certificate, its CRL and renewal information are served from ca.pem and
the database. So the acme and admin processes never read ca.key, never log
in to a PKCS#11 token and never use a relay’s upstream account. Make ca.key
(0600) readable by the worker’s uid alone; the others need ca.pem and the
database. A custom signer’s script must still be present where acme runs if
it serves the CRL or renewal information, since those hooks answer a request.
Three things to get right:
- Initialise once, first.
acme-proxy initmigrates the database and generates the CA key, the upstream account and any self-signed TLS certificate. Run it as the uid that should own those files. A process that does not runworkerrefuses to start against a schema that is behind, namingacme-proxy migrate, and without the CA certificate, namingacme-proxy init— so starting the others before the schema or the CA exists fails loudly rather than racing, and never generates a second CA. - Give each process its own
metrics.bind_address. The counters are per-process memory, so three processes are three scrape targets; sharing one address means the second one to start fails to bind. SetACME_PROXY_METRICS__BIND_ADDRESSper unit, point each at its own file withACME_PROXY_CONFIG, or turnmetrics.enabledoff where you do not want it. Every series carries arolelabel naming the roles that process runs, so one scrape config can tell them apart. - Run at least one worker. A process without it logs
server_role_no_workerat startup; a deployment without one issues nothing, because challenge validation, issuance, CRL signing, relay and custom revocations, notifications and the periodic sweeps are all queued work. A worker in another process picks a row up within the job poll interval, so clients see a second or so moreprocessingthan all-in-one; a revocation for a relay or custom profile still running when its request’s deadline nears answers503withRetry-After, and asking again follows it.
A worked topology, one systemd unit per role:
# acme-proxy-worker.service
ExecStart=/usr/local/bin/acme-proxy serve --role worker
Environment=ACME_PROXY_METRICS__BIND_ADDRESS=127.0.0.1:3002
# acme-proxy-acme.service
ExecStart=/usr/local/bin/acme-proxy serve --role acme
Environment=ACME_PROXY_METRICS__BIND_ADDRESS=127.0.0.1:3012
# acme-proxy-admin.service
ExecStart=/usr/local/bin/acme-proxy serve --role admin
Environment=ACME_PROXY_METRICS__BIND_ADDRESS=127.0.0.1:3022
One host, one filesystem — on SQLite. SQLite across processes is fine on a
local disk in WAL mode, and busy_timeout is already set — it is not safe on
NFS or across nodes. Multi-node needs PostgreSQL: point
database.url at a server instead of
a file, run acme-proxy migrate once, and the three roles can then live on
different hosts. Nothing else about the deployment changes.
An existing SQLite deployment moves across with its accounts, orders and audit
trail intact — stop the server, create and migrate the target, then
acme-proxy transfer --to <url>.
Starting the new deployment empty instead would leave every certificate it has
already issued impossible to revoke, so this is not an optional step.
Run one admin process either way; its login rate limiter is in memory, so two would each get their own budget.
Each process reloads independently on SIGHUP, so a configuration change means
reloading all three.
Upgrading
Replace the binary, migrate, and restart. The schema is append-only as of 0.1.0 — a new release only ever adds migrations, never rewrites the ones your database has already applied.
Migrations no longer run as a side effect of opening the database. acme-proxy serve running the worker role (which the default does) still applies them at
startup, so a single-process deployment can simply restart; anything else — an
admin command, or a split deployment’s acme/admin process — checks the
schema and refuses by name until acme-proxy migrate has run.
systemctl stop acme-proxy
install -m 0755 acme-proxy /usr/local/bin/acme-proxy
acme-proxy migrate # explicit; the default `serve` would also do it
systemctl start acme-proxy
journalctl -u acme-proxy -n 50
A container is upgraded the same way, with the image tag in place of the binary:
pull the new tag, migrate with it against the same /data, then recreate the
container on it. With Docker Compose, after changing the image: line:
docker compose pull
docker compose stop acme-proxy
docker compose run --rm acme-proxy migrate
docker compose up -d
docker compose logs -n 50 acme-proxy
migrate after the service name replaces the image’s default serve command
for that one container. A single-container deployment would also migrate as it
starts; running it as a step of its own stops the upgrade on the error, instead
of leaving a server in a restart loop. A split deployment must migrate before
any of its acme or admin containers start on the new tag. The advice below
applies unchanged, the database backup first of all.
Worth knowing before you do it:
- Take a copy of the database first. SQLite in WAL mode means three files;
copy them together with the server stopped, or use
sqlite3 acme.db ".backup backup.db"on a running one. Migrations are not reversible, so a downgrade means restoring this copy. db_migration_failedat startup means the process is not serving. The most likely cause is running an older binary against a database a newer one has already migrated.- Files outside the database are untouched. The CA key and certificate, the
exported
ca.crl, the upstream account key and its.kidsidecar all persist across an upgrade — back them up on the same schedule as the database, since the CA key is the one thing that cannot be regenerated without redistributing trust. See Trusting the CA. - Read the changelog’s
Breakingsection first. Before 1.0.0 the schema is the only compatibility guarantee: configuration keys, profile names, the admin JSON API, log event names and the CLI may all have moved, and every such change is listed there. See Compatibility. A renamed key is normally refused by name at startup — the server stops with an error naming the replacement rather than coming up looking configured — soacme-proxy filter showand a--helpare cheap pre-restart checks.