Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. Create a dedicated user:

    sudo useradd -r -s /bin/false acme-proxy
    
  2. 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
    
  3. 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-proxy shuts down gracefully on SIGTERM (and on Ctrl+C when run in a terminal), so systemctl restart and systemctl stop let in-flight requests finish rather than cutting them off. Both listeners stop together. It also reloads its configuration on SIGHUP without restarting, which is what the ExecReload line 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 — a custom script’s crl or renewal_info hook, or a relay revocation waiting on its job — can take up to server.request_timeout_ms. If systemd’s TimeoutStopSec (90 s by default) is shorter than that, systemd sends SIGKILL first 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.

  1. 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.enabled is 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 at pending.

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_url to 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_proxies to the proxy’s addresses and, if it is not x-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 U to the mount flags (-v ./data:/data:U, or :Z,U on SELinux systems). Podman then chowns the volume’s contents to the user the container runs as. Alternatives: podman unshare chown -R 1000:1000 ./data beforehand, 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 1000 before 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 Compose user: line) and own ./data yourself — 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.

RoleDoesHolds
acmeServes ACME to certificate clientsThe ACME listener
adminServes /ui and /apiThe admin listener
workerDrains the job queue; signs and revokes; owns the schema and the first-run materialThe 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:

  1. Initialise once, first. acme-proxy init migrates 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 run worker refuses to start against a schema that is behind, naming acme-proxy migrate, and without the CA certificate, naming acme-proxy init — so starting the others before the schema or the CA exists fails loudly rather than racing, and never generates a second CA.
  2. 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. Set ACME_PROXY_METRICS__BIND_ADDRESS per unit, point each at its own file with ACME_PROXY_CONFIG, or turn metrics.enabled off where you do not want it. Every series carries a role label naming the roles that process runs, so one scrape config can tell them apart.
  3. Run at least one worker. A process without it logs server_role_no_worker at 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 more processing than all-in-one; a revocation for a relay or custom profile still running when its request’s deadline nears answers 503 with Retry-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_failed at 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 .kid sidecar 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 Breaking section 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 — so acme-proxy filter show and a --help are cheap pre-restart checks.