Requirements & sizing
Host requirements and capacity planning: how many VMs, how much CPU, memory and disk for a given certificate count and discovery footprint, and which tunables actually move the numbers.
The installer checks the OS, the ports and the packages — it does not check CPU, memory or disk, and it will happily install onto an undersized host. The floors below are operational guidance, with the one hard exception called out under Minimum host: MongoDB does not run well under 4 GiB.
Minimum host
Per host, for the standalone install (application + nginx + a local MongoDB):
| Minimum | Notes | |
|---|---|---|
| Architecture | x86-64 or arm64 | glibc only. Alpine via the Docker path. |
| vCPU | 2 | The application is network-bound, not compute-bound — see Where the work goes. |
| Memory | 4 GiB | MongoDB's WiredTiger cache alone takes roughly half of (RAM − 1 GiB). Below 4 GiB the database is the first thing to suffer. |
| Disk | 20 GiB SSD | Mostly MongoDB. See Storage for the arithmetic. |
| Free ports | 443 (or --port), 18181 loopback, 27017 when Mongo is local | Standalone lists the layout. |
An application-only host — one that joins an existing cluster with --mongo=external, or the
fourth and later nodes of a --nodes install — has no database to feed and runs comfortably in
2 vCPU / 2 GiB.
Distros, ports and the outbound destinations the product dials are covered by Standalone deployment and Outbound network access; this page is only about capacity.
Where the work goes
Sizing follows the concurrency model, not the certificate count directly. Every CertAutoPilot
process started as --mode=all — which is what both the standalone installer and the Helm chart
run — contains three things in one binary:
- The API, which does almost nothing expensive. Requests are small Mongo reads and writes.
- Two worker lanes. A main worker (ACME, AD CS, notifications, discovery) and a dedicated
distribution worker, each of which claims up to
worker.max_concurrencyjobs at a time (default 4). One host therefore runs up to 8 jobs concurrently, no matter how many certificates exist. - The scheduler, leader-elected across the cluster through a MongoDB lock, so exactly one host sweeps at a time regardless of cluster size. Default sweep interval 1 hour.
What that means in practice:
| Activity | Bound by | Cost shape |
|---|---|---|
| Issuance / renewal | Certificate authority round-trips and DNS propagation | Wall-clock, not CPU. The phased DNS-01 flow deliberately releases its worker between polls, so a slow zone costs no lane. |
| Key generation | Key type | ECDSA P-256 (the default) is microseconds. RSA-4096 is the only routinely CPU-visible operation, and it runs once per renewal. |
| Distribution | Target device response times | One job holds one distribution lane for up to distribution_execution_timeout_minutes (default 30). Per-device locks serialise anything that would conflict. |
| Discovery | Concurrent TLS handshakes | One scan job holds one lane. Its connections are capped process-wide by worker.max_concurrent_discovery (default 50), so scans share a budget instead of multiplying. |
| Database | Working-set size | See Storage. At a few thousand certificates the whole working set fits in cache on any of the profiles below. |
The practical consequence: certificates buy you database, discovery buys you CPU. They are independent axes — size them separately.
Topology
| Topology | Hosts | MongoDB | Survives | Use when |
|---|---|---|---|---|
| Single VM | 1 | Local, standalone | Nothing — host loss is an outage | Evaluation, lab, one team, non-critical renewals |
| Multi-VM, external database | 2+ | Managed / existing replica set | Host loss, if the database is replicated | You already run MongoDB and want the app layer redundant |
3-VM cluster (--nodes) | 3 | 3-member replica set caprs, installed for you | Loss of any single host, including the database primary | The default recommendation for production |
| 3-VM cluster + app nodes | 3 + N | Replica set on the first three only | Same, with more API/worker capacity | Distribution or discovery throughput outgrows three hosts |
| Kubernetes | replicaCount pods | Yours to supply | Pod and node loss | You already run Kubernetes — see Helm chart |
Why three, not two
A two-host cluster with local databases is refused by the installer, and deliberately: MongoDB elects a primary by majority, and two members cannot form one when either dies. Three members tolerate the loss of one. The same number falls out of the application layer — the scheduler lock and every job claim are single-document atomic operations, so extra hosts are always safe, but below three there is no majority to elect anything.
Hosts four and upward run the application without a local mongod. They add API and worker
capacity — eight more concurrent job lanes each — and nothing else. Add them when lanes are the
constraint, not when the database is.
Full mechanics, firewall rules and failover behaviour: High availability.
Sizing profiles
Per host. "Certificates" means certificates CertAutoPilot manages — the license cap is a separate, independent limit.
| Profile | Certificates | Discovery | Topology | vCPU | Memory | Disk |
|---|---|---|---|---|---|---|
| Evaluation | ≤ 50 | Occasional /24 | 1 VM | 2 | 4 GiB | 20 GiB |
| Small production | ≤ 500 | A few /24s daily | 1 VM, or 3 for availability | 4 | 8 GiB | 50 GiB |
| Standard production | ≤ 2,500 | /22–/20 daily, CT feeds on | 3 VMs | 4 | 8 GiB | 100 GiB |
| Large / discovery-heavy | ≤ 2,500 | /16 sweeps, deep SNI, cipher enumeration | 3 VMs + app nodes | 8 | 16 GiB | 200 GiB |
Two notes on reading that table:
- The certificate column moves disk, not CPU. Going from 500 to 2,500 certificates roughly quintuples the database and changes nothing else, because renewals are paced by the scheduler and bounded by the worker lanes.
- The discovery column moves CPU, not memory. A /16 sweep with cipher enumeration is the only workload in the product that can saturate a small host, and it does so through concurrent TLS handshakes — see Discovery sizing.
2,500 certificates is the validated capacity figure for the product; the profiles stop there because that is what has been measured, not because a larger number is refused.
Discovery sizing
Discovery is the one workload worth sizing on its own, because its cost is set by settings rather than by inventory. It is also the most tightly bounded: every concurrency knob is capped process-wide, so no combination of sources can open an unbounded number of connections.
| Knob | Where | Default | Ceiling | What it bounds |
|---|---|---|---|---|
worker.max_concurrent_discovery | config.yaml | 50 | — | Process-wide concurrent scan dials, across every source and every lane on the host |
concurrency_limit | Per source | 20 | 100 | Scan worker goroutines for that source. Actual network I/O still passes through the process-wide cap above. |
max_targets | Per source | 512 | 65,536 | Addresses × ports one run may touch. The ceiling is one /16 scanned on a single port. |
timeout_seconds | Per source | 5 | 30 | Dial timeout. A dead address costs this long; a tarpit cannot hold a slot longer. |
| Crawl deadline | Per source | 1,800 s | — | Hard wall-clock budget per run, kept under the worker's 30-minute stuck-job threshold |
| Cipher-enumeration budget | Fixed | 15 min | — | Wall-clock cap on one source's whole cipher pass |
So the realistic ceiling on one host is ~50 concurrent scan handshakes plus up to 64 concurrent
cipher-enumeration handshakes — the cipher pass runs after the crawl drains, under its own
process-wide limit of 64, and never holds a scan slot. The systemd unit ships LimitNOFILE=65536,
so file descriptors are never the constraint; CPU for those handshakes is, which is why the
discovery-heavy profile doubles the cores rather than the memory.
Memory does not scale with the size of the scan. Results are persisted as they arrive rather
than buffered, so a run holds roughly one small entry per responding host:port — a /16 sweep that
finds 50,000 live endpoints costs tens of megabytes, not gigabytes.
Two optional per-source features add handshakes rather than concurrency: TLS version probing costs one extra 3-second handshake per endpoint, and cipher enumeration one connection per suite tested. Both lengthen a run; neither raises the ceilings above.
Storage
MongoDB is the only thing that grows. The figures below are measured avgObjSize values from a
running instance — use them as coefficients, not as promises.
| Collection | Bytes per document | Grows with | Bounded by |
|---|---|---|---|
certificate_artifacts | ~10,500 | certificates × retained versions | cert_history_max_versions (default 10) |
certificate_private_keys | ~2,600 | certificates × distinct keys | Same retention, pruned only when no retained artifact uses the key |
certificates | ~1,000 | certificates | — |
discovered_endpoints | ~550 | live endpoints found | 180-day TTL after an endpoint disappears |
discovered_certificates | ~970 | distinct certificates seen | — |
jobs | ~590 | renewals × jobs per renewal | 30-day TTL on terminal jobs |
job_logs | ~165 | log lines | 30-day TTL |
audit_logs | ~830 | operator and API actions | 30-day TTL |
cert_events | small | lifecycle events | 365-day TTL |
notification_history | small | notifications sent | 90-day TTL |
scheduler_sweep_runs | ~290 | sweeps | 7-day TTL |
Index overhead measured on the same instance is roughly 55–60% of data size. Budget total MongoDB footprint at about 1.6× the data figure.
Worked example
2,500 certificates at default retention:
artifacts 2,500 × 10.5 KB × 10 versions ≈ 260 MB
private keys 2,500 × 2.6 KB × ~1–10 keys ≈ 7–65 MB
certificates 2,500 × 1.0 KB ≈ 2.5 MB
----------
data ≈ 270–330 MB
+ indexes (×1.6) ≈ 430–530 MB
Add the churn collections, all of which are TTL-bounded rather than cumulative: at a 47-day certificate lifetime and a 30-day renewal threshold, 2,500 certificates renew roughly 150 times a day, producing on the order of 1,000 job documents and 6,000 log lines daily — under 100 MB inside the 30-day window, indexes included. Discovery adds ~550 bytes per live endpoint.
A 2,500-certificate deployment is therefore well under 2 GiB of database. Disk goes to the
operating system, the journal, and — if you enabled --enable-backup — seven nightly dumps, each
roughly the size of the compressed data. The 50–200 GiB in the profile table is headroom for those
and for growth, not a prediction of the data size.
Lowering cert_history_max_versions is the single most effective storage lever: it is a direct
multiplier on the largest collection. Raising it is how you keep more rollback
targets available, which is the trade.
Tunables that matter
| Setting | Where | Default | Range | Raise it when |
|---|---|---|---|---|
worker.max_concurrency | Config file / CERTAUTOPILOT_WORKER_MAX_CONCURRENCY | 4 | ≥ 1 | Jobs queue while CPU sits idle. Applies to both lanes, so the real ceiling is 2× this per host. |
distribution_fanout_threshold | Settings → General | 0 (off) | 0–500 | A distribution has more targets than one 30-minute job can finish. Splits it into batch children. |
distribution_fanout_batch_size | Settings → General | 50 | 0–200 | Batches finish too fast (raise) or time out (lower). |
distribution_ssh_max_concurrency | Settings → General | module default 10 | 0–50 | SSH fan-outs are the bottleneck. Per-module concurrency is clamped to 1–50 everywhere. |
distribution_execution_timeout_minutes | Settings → General | 30 | 5–120 | Slow appliances abort mid-run. |
renewal_check_interval_minutes | Settings → General | 60 | — | Rarely. The sweep is cheap; the certificate authority is the slow part. |
cert_history_max_versions | Settings → General | 10 | −1 (unlimited) – 100 | You need deeper rollback history, and you have the disk. |
worker.max_concurrent_discovery | config.yaml only | 50 | ≥ 1 | Scans are slow while the host has spare CPU. This is the process-wide handshake budget — raising a source's own concurrency_limit does nothing until this moves. The key is absent from the standalone template, so the CERTAUTOPILOT_* env var is silently ignored until you add it by hand (configuration reference). |
concurrency_limit / max_targets | Per discovery source | 20 / 512 | ≤ 100 / ≤ 65,536 | Scans take too long — but read Discovery sizing first. |
Kubernetes
The chart's shipped requests and limits — 100m/128Mi requested, 500m/256Mi limited — are sized for an API-shaped pod. They are not enough for a pod that wins a large discovery job or a wide distribution fan-out: the same process runs the workers. If you scan anything larger than a /24, raise the limits to at least 1 CPU / 1 GiB before you tune anything else.
The chart runs --mode=all in every replica with a leader-elected scheduler, so replicaCount
scales lanes exactly like adding VM nodes does — eight concurrent jobs per replica at the default
concurrency. See Helm chart.
When to grow
Signals, in the order they usually appear:
- Jobs sit in
pendingwhile CPU is idle — lane starvation. Raiseworker.max_concurrency, then add application nodes. - Distribution jobs hit the 30-minute timeout — turn on fan-out; the threshold defaults to off.
- Discovery runs report partial coverage — the crawl budget expired. Lower
max_targets, split the source, or add cores. - MongoDB read latency climbs — the working set no longer fits the WiredTiger cache. Add memory before adding hosts; a replica set does not make reads faster when they default to the primary.
- Disk growth tracks renewals — check
cert_history_max_versionsbefore buying disk.