Build a Certificate Manager
Certificate Manager — a complete walkthrough of x.509 certificate lifecycle, covering acme client, order tracker, challenge solver, and testing.
Project goal
Implement x.509 certificate lifecycle that solves requesting, renewing, and serving certificates before expiry without manual intervention, with each component independently testable.
Final features
- ACME client
- Order tracker
- Challenge solver
- Cert store
- Renewal scheduler
Architecture
The system is organized around acme client, order tracker, challenge solver, and cert store ties them together. Data flows as a set of certificate orders each with a status, a challenge, and a stored key pair plus chain on success.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with x.509 certificate lifecycle fundamentals
- A local environment to run the test suite
Dependencies
- acme-client ^5.2.0 — ACME protocol
- node-forge ^1.3.1 — Certificate parsing
Directory structure
certificate_manager/
├── src/index.ts
├── src/acme-client.ts
├── src/order-tracker.ts
├── tests/certificate_manager.test.ts
└── package.json
Phases
1. ACME client
Build the acme client that anchors x.509 certificate lifecycle and defines the core data shape.
// ACME client: x.509 certificate lifecycle core
export function acmeclient(input: AcmeclientInput): AcmeclientResult {
// Build the acme client that anchors x.509 certificate lifecycle and defines the core data shape.
return processAcmeclient(input);
}
Expected behavior: After this phase the acme client works in isolation and is covered by a unit test.
2. Order tracker
Add the order tracker so x.509 certificate lifecycle can requesting, renewing, and serving correctly.
// Order tracker — handles x.509 certificate lifecycle Add the order tracker so
export class Ordertracker {
constructor(private opts: OrdertrackerOptions) {}
async run(ctx: Context): Promise<OrdertrackerOutput> {
return this.execute(ctx);
}
}
Expected behavior: After this phase the order tracker works in isolation and is covered by a unit test.
3. Challenge solver
Wire the challenge solver to handle a challenge solver that without losing data.
// Challenge solver: x.509 certificate lifecycle stage
export const challengesolver = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
Expected behavior: After this phase the challenge solver works in isolation and is covered by a unit test.
4. Cert store
Integrate the cert store to make x.509 certificate lifecycle observable and testable end to end.
// Cert store for x.509 certificate lifecycle
export async function certstore(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
Expected behavior: After this phase the cert store works in isolation and is covered by a unit test.
5. Renewal scheduler
Finalize the renewal scheduler so the x.509 certificate lifecycle system degrades gracefully under overload.
// Renewal scheduler: x.509 certificate lifecycle finalization
export function renewalscheduler(state: State): Result {
validate(state);
return finalize(state);
}
Expected behavior: After this phase the renewal scheduler works in isolation and is covered by a unit test.
Testing
Each phase has a focused test: acme client, order tracker, challenge solver are exercised in isolation, then an integration test drives the full x.509 certificate lifecycle pipeline and asserts the a challenge solver that case is handled.
Troubleshooting
A challenge solver that fails behind a restrictive firewall and lets the certificate expire silently
Reproduce with a minimal fixture, then add a guard test so the x.509 certificate lifecycle system rejects the condition loudly.
Performance regresses under load
Profile with the sampling analyzer, locate the hot path, and add a targeted benchmark to lock the regression.
Deployment
Run two instances behind a load balancer, share state in Redis, and set resource limits so overload surfaces as backpressure rather than failure.
Known limitations
- Single-region; multi-region replication is out of scope.
- The acme client is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
Project overview
This project builds a x.509 certificate lifecycle system whose reason for existing is requesting, renewing, and serving certificates before expiry without manual intervention. The temptation in x.509 certificate lifecycle is to bolt the interesting part onto a working prototype and discover the hard cases in production; this build inverts that order and treats a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently as the first thing designed, not the last.
You will implement ACME client, Order tracker, Challenge solver, Cert store, and Renewal scheduler, each as a module with one responsibility and a test that proves it in isolation. The x.509 certificate lifecycle pipeline is then the wiring that composes them, and the integration test is where the composition is shown to survive a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently.
The walkthrough is written for a developer who has used a x.509 certificate lifecycle system but has not had to make one honest. It favors explicit data shapes over clever abstractions, and it names the failure each design choice prevents rather than presenting the choice as a style preference.
Each phase ends with something observable — a acme client that returns the right shape, a order tracker that rejects bad input, an integration test that goes green — so you can stop at any point and still have a x.509 certificate lifecycle system that works for the cases covered so far.
What you will build
The finished x.509 certificate lifecycle system exposes five capabilities, each with a focused test:
- ACME client
- Order tracker
- Challenge solver
- Cert store
- Renewal scheduler
ACME client is the foundation that every later stage assumes; Order tracker is where x.509 certificate lifecycle correctness lives; Challenge solver handles a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently; Cert store makes the system observable; and Renewal scheduler is the graceful-degradation layer that keeps overload from becoming corruption.
The build keeps the x.509 certificate lifecycle logic in ordinary modules you can read in one sitting, and pulls in dependencies only for the parts that are genuinely tedious to hand-roll. That keeps the diff between the tutorial and a real codebase small, and keeps the failure modes visible in code rather than hidden behind a framework.
Architecture and data model
The system is organized around ACME client, Order tracker, and Challenge solver, with Cert store tying them together. Data flows as a set of certificate orders each with a status, a challenge, and a stored key pair plus chain on success.
Each component is a module with a pure interface where practical: the same input produces the same output, and side effects are pushed to the edges. That discipline is what makes the phases independently testable, because a test for Challenge solver does not need to stand up ACME client and Order tracker to exercise the interesting branch.
The data model is deliberately explicit. A set of certificate orders each with a status, a challenge, and a stored key pair plus chain on success Keeping the shape written down — in a schema, a type, or a fixture — means a change to the shape is a visible diff rather than a silent runtime shift, and it means the integration test can assert against a concrete shape instead of guessing.
Configuration and code are kept apart: the configuration says what x.509 certificate lifecycle should do, the modules say how. A reviewer can change a threshold or a policy without reading the implementation, which keeps the risky changes small and lets the diff stand as the review.
The failure path through Challenge solver is designed before the happy path is finished, because a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently is the case that distinguishes a x.509 certificate lifecycle system from a demo. Every component declares what it does when its inputs are wrong, and the integration test asserts each of those declarations.
Prerequisites and dependencies
Have these ready before you install anything, so the x.509 certificate lifecycle setup is not blocked by a missing tool:
- Node.js 20 LTS or later
- Familiarity with x.509 certificate lifecycle fundamentals
- A local environment to run the test suite
The runtime dependencies are chosen to keep the x.509 certificate lifecycle datapath legible:
acme-client(^5.2.0) — ACME protocolnode-forge(^1.3.1) — Certificate parsing
# setup for certificate-manager
pnpm init
pnpm add acme-client node-forge
pnpm add -D typescript tsx vitest
Versions are pinned to a major range so the x.509 certificate lifecycle install is reproducible; commit the lockfile. The acme client reads its operational parameters from the environment and validates them at startup, so a missing value fails the boot rather than the first x.509 certificate lifecycle request.
Directory structure
Keep each x.509 certificate lifecycle component in its own file so it can be tested without starting the others:
certificate_manager/
├── src/index.ts
├── src/acme-client.ts
├── src/order-tracker.ts
├── tests/certificate_manager.test.ts
└── package.json
Phase 1: ACME client
Build the acme client that anchors x.509 certificate lifecycle and defines the core data shape.
// ACME client: x.509 certificate lifecycle core
export function acmeclient(input: AcmeclientInput): AcmeclientResult {
// Build the acme client that anchors x.509 certificate lifecycle and defines the core data shape.
return processAcmeclient(input);
}
After this phase the acme client works in isolation and is covered by a unit test. The acme client defines the data shape that every later stage assumes, so getting it right first means the later phases compile against a stable contract.
Treat the acme client as the single source of truth for the x.509 certificate lifecycle data shape. Validate inputs at the boundary and reject early, so a malformed value never reaches Order tracker or Challenge solver where its effect would be harder to localize.
Phase 2: Order tracker
Add the order tracker so x.509 certificate lifecycle can requesting, renewing, and serving correctly.
// Order tracker — handles x.509 certificate lifecycle Add the order tracker so
export class Ordertracker {
constructor(private opts: OrdertrackerOptions) {}
async run(ctx: Context): Promise<OrdertrackerOutput> {
return this.execute(ctx);
}
}
After this phase the order tracker works in isolation and is covered by a unit test. This is where x.509 certificate lifecycle correctness lives: the order tracker is what makes requesting, renewing, and serving certificates before expiry without manual intervention true, and its test is the proof.
Keep the order tracker pure where you can. A function that takes an input and returns an output is trivial to test and impossible to mock wrong; a class with hidden state is the opposite. Push side effects to the caller and keep the order tracker about the transformation.
Phase 3: Challenge solver
Wire the challenge solver to handle a challenge solver that without losing data.
// Challenge solver: x.509 certificate lifecycle stage
export const challengesolver = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
After this phase the challenge solver works in isolation and is covered by a unit test. The challenge solver is the difference between a x.509 certificate lifecycle system that works on the happy path and one that survives a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently.
Design the failure before the success. Decide what challenge solver does when a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently: reject, retry, shed, or degrade — and write that test first. A x.509 certificate lifecycle system whose failure behavior is unspecified will pick one at random under pressure, and it is usually the wrong one.
Phase 4: Cert store
Integrate the cert store to make x.509 certificate lifecycle observable and testable end to end.
// Cert store for x.509 certificate lifecycle
export async function certstore(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
After this phase the cert store works in isolation and is covered by a unit test. Observability is not a finishing touch; it is how you know the x.509 certificate lifecycle system is doing what it claims in production.
Emit one structured event per meaningful action, with enough context to localize a failure without a second round-trip. The cert store is what turns “something is slow” into “the order tracker is slow for inputs matching this shape,” which is the difference between an alert and an investigation.
Phase 5: Renewal scheduler
Finalize the renewal scheduler so the x.509 certificate lifecycle system degrades gracefully under overload.
// Renewal scheduler: x.509 certificate lifecycle finalization
export function renewalscheduler(state: State): Result {
validate(state);
return finalize(state);
}
After this phase the renewal scheduler works in isolation and is covered by a unit test. The renewal scheduler is what keeps overload from becoming corruption: when the x.509 certificate lifecycle system is asked for more than it can do, it degrades in a defined way instead of failing in an undefined one.
Pick a degradation strategy for renewal scheduler — shed, queue, or backpressure — and make it explicit in code and in a test. Leaving the behavior to chance is how a x.509 certificate lifecycle traffic spike becomes a data loss incident.
Testing
Each phase has a focused test: acme client, order tracker, and challenge solver are exercised in isolation, then an integration test drives the full x.509 certificate lifecycle pipeline and asserts the a challenge solver that case is handled.
Start with unit tests that pin each component’s contract: the acme client returns the documented shape, the order tracker transforms correctly, and the challenge solver fails in the documented way. These tests are fast and they localize regressions to the component that broke.
Then add an integration test that composes the components and drives a realistic x.509 certificate lifecycle scenario end to end. Assert the happy path first, then deliberately trigger a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently and assert the system degrades rather than corrupts. Because the components are pure where practical, the integration test stays deterministic rather than flaky.
A x.509 certificate lifecycle suite must also prove the negative: bad input is rejected at the boundary, overload is shed rather than queued indefinitely, and a partial failure does not leave the system in an inconsistent state. These are the tests that catch the regressions a happy-path suite never would.
The entire x.509 certificate lifecycle suite runs with no external service; every dependency is either in-process or stubbed, so every case is reproducible in CI. That is what lets the challenge solver failure-mode assertions live as tests rather than as a manual checklist that drifts out of date.
# run the x.509 certificate lifecycle test suite
pnpm vitest run
Deployment and operations
Run at least two x.509 certificate lifecycle instances behind a load balancer and share state in Redis. Set resource limits so x.509 certificate lifecycle overload surfaces as backpressure rather than failure.
Run at least two stateless instances behind a load balancer, and move any shared state into a shared store so the x.509 certificate lifecycle behavior is global rather than per instance. Size limits so the system sheds load before it runs out of memory, because a x.509 certificate lifecycle system that OOMs under load fails in a way that is hard to reason about.
Drain on deploy: stop accepting new work, let in-flight x.509 certificate lifecycle operations finish, then exit. Because the system holds no state that cannot be reconstructed, a rollout is a matter of cycling instances one at a time while watching the error rate.
When a dependency is unavailable, decide explicitly whether the x.509 certificate lifecycle system fails closed or degrades. Leaving that choice to chance is how a dependency outage becomes a silent data loss, so make the choice visible in code and in a test.
Security considerations
Validate all x.509 certificate lifecycle inputs at the boundary, reject oversized payloads before buffering them, and keep secrets in the environment rather than the repository.
The x.509 certificate lifecycle system is a choke point, so it is the right place to reject malformed input before it reaches order tracker or challenge solver. Validate the shape and the size at the edge, and let the inner components assume what they receive is well-formed.
Never log secrets or full payloads; log enough to localize a failure without recording data that should not be on disk. Treat any external call as untrusted and validate its response before using it, because a x.509 certificate lifecycle system that trusts its inputs blindly is one malformed response away from corruption.
Keep dependencies pinned and run an audit in CI. A transitive dependency with a known vulnerability is still a vulnerability in your x.509 certificate lifecycle system, and the lockfile is what makes the audit meaningful.
Observability
Emit one structured event per x.509 certificate lifecycle operation with the inputs’ shape, the outcome, and a correlation id. Track error rate and latency per component and alert on a step change rather than a single spike.
The correlation id is the thread that ties a user-visible symptom to the x.509 certificate lifecycle decision and to the component log entry for the same operation. Without it, an investigation starts from a timestamp and a guess.
Watch the challenge solver failure rate separately from the overall error rate. A jump in challenge solver failures usually means a challenge solver that fails behind a restrictive firewall and lets the certificate expire silently, not a bug in order tracker, and the two have different owners and different fixes.
Chart the share of x.509 certificate lifecycle operations that shed, queue, or succeed. A change in the mix points at the cause faster than an aggregate latency can, because each class has a different root.
Troubleshooting
A challenge solver that fails behind a restrictive firewall and lets the certificate expire silently. Reproduce with a minimal fixture, then add a guard test so the x.509 certificate lifecycle system rejects the condition loudly.
Performance regresses under x.509 certificate lifecycle load. Profile with the sampling analyzer, locate the hot path in order tracker, and add a targeted benchmark to lock the regression.
Keep the x.509 certificate lifecycle dashboards separate from dependency dashboards. When an operation fails, the correlation id lets you decide in seconds whether the fault is acme client, order tracker, or challenge solver, instead of guessing from a timeout.
Limitations and next steps
- Single-region; multi-region replication is out of scope.
- The acme client is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
The x.509 certificate lifecycle system is single-region by design; extending to multi-region means making acme client shareable across regions, which is a real change to the consistency model rather than a config flag. The challenge solver is in-process, which is correct for one instance but must move to a shared store before scaling out.
There is no authentication or authorization layer; the system assumes it is deployed behind an edge that enforces identity. Adding one is straightforward but out of scope for this build, and doing it here would distract from the x.509 certificate lifecycle correctness that is the point.
The natural next step, once the x.509 certificate lifecycle pipeline is trusted in production, is to add a short-TTL cache for idempotent reads and a richer cert store that exports histograms rather than counters. Both are additive and neither changes the contracts this build establishes.