A small Certificate Authority service with a REST API and web UI. Create CAs,
issue certificates, publish CRLs, and download PEM/PKCS#12 bundles — without ever
touching an openssl command line.
This is a ground-up Rust rewrite of the original minica (Kotlin + Spring Boot + Angular). It keeps the same mental model — CAs and certificates managed over a Basic-auth REST API with admin/viewer roles — while replacing the runtime, storage, and operational story with something smaller, safer, and easier to run.
-
Single static binary, two dependencies. The service is one Rust binary that shells out to
openssl. There is no JVM, no application server, and no JDKkeytoolto install — the only runtime requirement isopensslonPATH. Rationale: the original needed JDK 17+, a Spring Boot fat JAR, andkeytool; this removes an entire language runtime from the deployment. -
Database-backed, durable state. All CAs, certificates, users, and CRLs live in a bundled SQLite database rather than loose files in a directory tree. Rationale: state is transactional and easy to snapshot, instead of being spread across per-CA folders on disk.
-
First-class CRL support. Every CA gets a CRL that is (re)generated on creation, import, and revocation; issued certs can embed a
crlDistributionPointsURL, and the DER CRL is served at/crl/{ca_id}. Rationale: the original had no certificate revocation at all — once issued, a cert could not be revoked. -
Backup & restore. One YAML export captures everything durable — users (bcrypt hashes), active and soft-deleted CAs/certs, all private material,
index.txt/serial.txt, and the CRLs — and restores byte-for-byte into an empty database inside a transaction. Rationale: real disaster recovery from a single file, versioned for forward compatibility. -
Safer multi-user auth. A single bootstrap admin lives in the config file; all other accounts are managed in the database with bcrypt-hashed passwords via an Admin Console, with admin/viewer roles. Rationale: you don't keep every user's plaintext password in a properties file.
-
Concurrency-safe revocation. Revocation takes a per-CA lock and persists the new
index.txt, serial, and CRL atomically, releasing the lock on error. Rationale: concurrent revokes can't corrupt the CA's OpenSSL database. -
Soft delete & restore. CAs and certificates are soft-deleted and can be restored from the Admin Console. Rationale: an accidental delete is recoverable.
-
Bounded, self-cleaning OpenSSL execution. Each
opensslinvocation runs in a throwaway working directory with a wall-clock timeout (SIGTERM then SIGKILL), and abandoned workdirs are swept periodically. Rationale: a hung or crashed subprocess can't leak processes or temp files. -
OpenAPI / Swagger UI. The REST API is documented and explorable in-browser at
/swagger. Rationale: discoverable, testable API instead of README-only docs. -
Companion Go CLI. A small
minica certCLI drives the API end-to-end — create a cert and save the PEM, key, PKCS#12, its password, and the CA cert — configured by flags,MINICA_*env vars, or a~/.minicafile. Rationale: automate issuance from scripts without hand-rolling the API calls and CSRF handling.
| Aspect | Original minica | MiniCA (Rust) |
|---|---|---|
| Runtime | JVM + Spring Boot + JDK keytool |
Single Rust binary + openssl |
| Storage | Per-CA directories on disk | Bundled SQLite database |
| Revocation / CRL | None | CRL generation, revocation, distribution points |
| Backup/restore | Copy the directory tree | One transactional YAML export/restore |
| User passwords | Plaintext in config | Bootstrap admin in config; DB users bcrypt-hashed |
| Delete safety | Hard delete | Soft delete with restore |
| Subprocess safety | — | Timeout-bounded, self-cleaning workdirs |
| Automation | REST API | REST API + OpenAPI/Swagger + Go CLI |
In short: the original proved the model — a friendly REST/UI front end over
openssl so you never memorize its flags. This rewrite keeps that ergonomics
win and hardens everything around it: fewer moving parts to deploy, durable and
backup-able state, real revocation, hashed credentials, and safe concurrency.
- No JKS / Java truststore output. Dropping the JDK dependency also drops
keytool-produced JKS keystores and truststores; downloads are PEM and PKCS#12. If your toolchain needs JKS, convert from the PKCS#12 bundle. - CRLs are not auto-refreshed on a timer. A CRL is regenerated on
create/import/revoke, so a quiet CA can serve a CRL past its
nextUpdateuntil the next change.
-
Configure. Generate a starter config from the bundled sample, then edit it:
minica --gen-config # writes ./config.yaml (won't overwrite an existing one)Set the server bind/port and
base_path,public_base_url(used for CRL distribution URLs and links behind a proxy), theopensslpath, and the bootstrapadminuser. The bootstrap password may be a bcrypt hash (recommended — generate one withminica --gen-password) or plaintext; a plaintext bootstrap password still works but logs a warning at startup. All other users are bcrypt-hashed in the DB. -
Run the service.
cargo run --release -- --start -c config.yaml # or, from a built binary: ./minica --start -c config.yamlThe UI and API are served under
base_path(default/minica); Swagger is at/minica/swagger. Runminica --helpto see all actions (--start,--gen-config,--gen-password,--verify-password). -
Issue certs from the CLI. See cli/README.md:
cd cli && go build -o mcacli . MINICA_URL=http://127.0.0.1:9988/minica MINICA_USER=admin \ MINICA_PASSWORD=adminpass MINICA_CA_ID=<ca-id> \ ./mcacli cert --cn test1.example.com --hostnames a.com,b.com,10.0.0.5
Any value in the config file may be written as {{ENV:VAR:default}}: it
resolves to the environment variable VAR when set, otherwise to the default
after the second colon. {{ENV:VAR}} with no default resolves to an empty
string when the variable is unset. Variable names and fallback values are
trimmed. Variable names must match [A-Za-z_][A-Za-z0-9_]*. Fallback values
may contain colons, and percent escapes in fallbacks are decoded on a
best-effort basis, so }} can be written as %7D%7D. Only strict %HH hex
escapes are decoded; + is left as a literal plus. Resolution happens after
the file is read and before YAML parsing, so tokens don't need quoting and may
expand to any YAML value.
auth:
users:
enabled: {{ENV:MINICA_BASIC_AUTH_ENABLE:true}}
list:
- username: {{ENV:MINICA_ADMIN_USER:admin}}
password: {{ENV:MINICA_ADMIN_PASSWORD:adminpass}}
role: admin
headers:
enabled: {{ENV:MINICA_HEADER_AUTH_ENABLE:false}}
trusted_remotes:
- '{{ENV:MINICA_TRUSTED_REMOTE_1:-}}'config.yaml.docker is a fully parameterised template —
every setting has a MINICA_* variable whose default matches
config.yaml.example, except that the container image overrides mutable paths
under /data. Treat /data as persistent storage: use a Docker/Podman named
volume, a bind mount, or a Kubernetes PersistentVolumeClaim. If /data is not
persistent, the SQLite database and generated CA/certificate state are lost when
the container is removed.
/data/runtimefor runtime state/data/sqlitefor the SQLite database folder/data/logs/minica.logfor logs/data/runtime/openssl-workfor OpenSSL temporary workdirs
Build the local image with Podman:
docker/build.sh minica:localRun it with /data mounted:
podman run --rm -p 9988:9988 -v minica-data:/data \
-e MINICA_ADMIN_PASSWORD='$2b$12$...' \
minica:localDocker uses the same image recipe. If you prefer Docker instead of Podman:
./build.sh
docker build -f docker/Dockerfile -t minica:local .
docker run --rm -p 9988:9988 -v minica-data:/data \
-e MINICA_ADMIN_PASSWORD='$2b$12$...' \
minica:localWhen MiniCA resolves environment tokens at startup, it prints each resolved
variable to stderr. Password-like names are masked (MINICA_ADMIN_PASSWORD -> a*******s), and any set MINICA_ variable not referenced by the loaded config
prints a warning.
Common Docker-friendly variables:
| Variable | Default | Purpose |
|---|---|---|
MINICA_HOST |
0.0.0.0 |
Server bind address. |
MINICA_PORT |
9988 |
Server port. |
MINICA_BASE_PATH |
/minica |
UI/API base path. |
MINICA_PUBLIC_BASE_URL |
http://127.0.0.1:9988/minica |
External URL used in links and CRL distribution points. |
MINICA_CRL_ENABLED |
true |
Enable CRL generation and serving. |
MINICA_CRL_NEXT_UPDATE_DAYS |
30 |
CRL nextUpdate interval. |
MINICA_RUNTIME_FOLDER |
/data/runtime in the image |
Runtime state folder. |
MINICA_DB_FOLDER |
/data/sqlite in the image |
Folder containing db.sqlite. |
MINICA_LOG_FILE |
/data/logs/minica.log in the image |
Log file path. |
MINICA_LOG_ROTATE_SIZE_BYTES |
10485760 |
Log rotation size. |
MINICA_LOG_MAX_BACKUPS |
10 |
Number of rotated log files to keep. |
MINICA_LOG_COMPRESS |
true |
Compress rotated logs. |
MINICA_OPENSSL_PATH |
/usr/bin/openssl |
OpenSSL binary path. |
MINICA_OPENSSL_TIMEOUT_SECONDS |
15 |
Per-command OpenSSL timeout. |
MINICA_OPENSSL_WORKING_ROOT |
/data/runtime/openssl-work in the image |
OpenSSL temporary working root. |
MINICA_OPENSSL_KEEP_FAILED_WORKDIRS |
false |
Keep failed OpenSSL workdirs for debugging. |
MINICA_OPENSSL_REAP_AFTER_HOURS |
24 |
Age threshold for cleaning abandoned OpenSSL workdirs. |
MINICA_BASIC_AUTH_ENABLE |
true |
Enable config/bootstrap Basic auth. |
MINICA_ADMIN_USER |
admin |
Bootstrap admin username. |
MINICA_ADMIN_PASSWORD |
adminpass |
Bootstrap admin password or bcrypt hash. Use minica --gen-password. |
MINICA_ADMIN_ROLE |
admin |
Bootstrap account role. |
MINICA_HEADER_AUTH_ENABLE |
false |
Enable reverse-proxy header auth. |
MINICA_HEADER_USERNAME |
Remote-User |
Header carrying the authenticated username. |
MINICA_HEADER_GROUP |
Remote-Groups |
Header carrying user groups. |
MINICA_HEADER_ADMIN_GROUP |
admin |
Group that grants admin role. |
MINICA_HEADER_VIEWER_GROUP |
user |
Group that grants viewer role. |
MINICA_TRUSTED_REMOTE_1 ... MINICA_TRUSTED_REMOTE_10 |
- |
Trusted reverse-proxy peers, as IPs or CIDRs. Unset slots are ignored; no trusted peers means trust every remote. |
podman run --rm -p 8443:8443 -v minica-data:/data \
-e MINICA_PORT=8443 \
-e MINICA_ADMIN_PASSWORD='$2b$12$...' \
-e MINICA_HEADER_AUTH_ENABLE=true \
-e MINICA_TRUSTED_REMOTE_1=10.0.0.7 \
minica:localDocker equivalent:
docker run --rm -p 8443:8443 -v minica-data:/data \
-e MINICA_PORT=8443 \
-e MINICA_ADMIN_PASSWORD='$2b$12$...' \
-e MINICA_HEADER_AUTH_ENABLE=true \
-e MINICA_TRUSTED_REMOTE_1=10.0.0.7 \
minica:localMiniCA shells out to the openssl binary, and every subcommand it uses
(genpkey, req, ca — including copy_extensions, -gencrl, -revoke,
-crl_reason — crl, pkcs12 -export, verify, x509) is also provided by
LibreSSL; the one OpenSSL-only flag MiniCA used (x509 -ext) has an automatic
fallback. To use LibreSSL, just point the config at its binary:
openssl:
path: /usr/bin/openssl # OpenBSD (LibreSSL is the system openssl)
# path: /usr/local/opt/libressl/bin/openssl # Homebrew libressl
# path: /usr/bin/eopenssl33 # some Linux libressl packagesCaveats:
- PKCS#12 encryption defaults. LibreSSL's
pkcs12 -exportstill defaults to 3DES for keys and 40-bit RC2 for certificates (OpenSSL 3 uses AES-256 + PBKDF2). The resulting.p12bundles import fine into Windows, Java, and browsers, but reading an RC2-encrypted bundle with an OpenSSL 3.x client needs its legacy provider (openssl pkcs12 -legacy ...). - Compatibility was verified against the LibreSSL manual and sources; if you
hit an issue with a specific LibreSSL version, set
openssl.keep_failed_workdirs: trueand check the logged command that failed.
MiniCA can trust an authenticating reverse proxy (Authelia, oauth2-proxy,
Traefik forward-auth, ...) to identify users via request headers. You can run
this instead of Basic auth, or alongside Basic auth. In header-only mode no
local account is needed at all. Both auth.users and auth.headers may be
configured side by side, each with an enabled toggle (default true when the
section is present). At least one mode must be enabled. When both are enabled,
requests with a header-auth identity use header auth; requests without identity
headers fall back to Basic auth, which keeps CLI access usable beside
browser/proxy SSO.
When auth.headers.enabled and auth.users.enabled are both true, MiniCA
uses this order:
- If the configured username header is missing or empty, Basic auth handles the request.
- If the username header is present, header auth handles the request; failed trust or group checks do not fall back to Basic auth.
- A header-auth user in
admin_groupgets admin access, even if they are also inviewer_group. - A header-auth user in
viewer_groupplus any other non-admin groups gets viewer access. - A header-auth user in neither
admin_groupnorviewer_group, or with no group header, is denied.
auth:
users:
enabled: false # or omit the whole section
list: []
headers:
enabled: true
username: Remote-User # header carrying the authenticated user id
group: Remote-Groups # header carrying the group list
admin_group: admin # group that grants the admin role
viewer_group: user # group that grants the viewer role
# Honor identity headers only from these peers (IPs or CIDRs) — typically
# the upstream reverse proxy. Omit or leave empty to trust every remote.
#trusted_remotes:
# - 127.0.0.1
# - 10.0.0.0/8- The group header accepts
a,b,a;b, or a JSON array["a","b"]; group names match case-insensitively. - There is no browser login prompt for header-auth failures.
trusted_remotesmatches the TCP peer of the connection, notX-Forwarded-For— behind a chain of proxies, list the last hop. A request from an untrusted peer gets a page naming the declared identity, e.g. user 'xyz' was declared by untrusted remote 192.168.1.5.- Only enable this when MiniCA is reachable exclusively through the proxy, or
pin the proxy with
trusted_remotes. CSRF protection stays enabled.
Note: Like the original, this is intended for development and internal/test environments where standing up a full enterprise PKI is overkill.