Why We Replaced Let's Encrypt with Self-Signed Certs on a Private Mesh
When public CA trust chains become unnecessary overhead for sovereign infrastructure.
For months, our three Hetzner servers ran with Let's Encrypt certificates. Automatic renewal, trusted by every browser, no fuss. But as we moved deeper into a sovereign stack—where every service lives behind a WireGuard mesh and no public DNS touches internal endpoints—the value of a public CA evaporated. We replaced Let's Encrypt with a self-signed internal CA. Here's why, and how we did it.
The Problem with Public CAs in a Private Mesh
Let's Encrypt is brilliant for public-facing services. But on a private mesh, the trust model is inverted. Our services never talk to the public internet: RiNET's Qwen inference runs on vLLM, BGE-M3 embeddings, Postgres, Qdrant, Neo4j—all bound to WireGuard IPs (10.0.0.x). No browser, no curl from outside. Every client is a known machine with a WireGuard key.
With Let's Encrypt, we had to:
- Expose a DNS challenge endpoint (or use HTTP-01, which requires port 80 open).
- Trust a third party to not revoke our certs (unlikely, but possible).
- Handle renewal failures when the mesh was partitioned (we saw this during a Hetzner maintenance window).
- Maintain a dependency on
certbotand its Python stack on every node.
None of these are showstoppers, but they add complexity. For a three-server mesh, the overhead of a public CA is disproportionate to the benefit.
The Self-Signed Alternative
We built a minimal internal CA using OpenSSL. Each server gets a certificate signed by our root CA key. The root CA's public certificate is distributed to all nodes via our WireGuard-provisioned Ansible playbook. Every service trusts the root CA, and nothing else.
Step 1: Generate the Root CA
# On a secure offline machine (or a dedicated management container)
mkdir -p ca/private ca/certs
chmod 700 ca/private
openssl genrsa -out ca/private/ca.key 4096
openssl req -x509 -new -nodes -key ca/private/ca.key -sha256 -days 3650 \
-out ca/certs/ca.crt \
-subj "/C=XX/ST=Private/O=RiNET Mesh/CN=RiNET Root CA"Step 2: Issue Server Certificates
For each server (e.g., mesh-1, mesh-2, mesh-3):
# Generate a private key and CSR
openssl genrsa -out servers/mesh-1.key 2048
openssl req -new -key servers/mesh-1.key -out servers/mesh-1.csr \
-subj "/C=XX/ST=Private/O=RiNET Mesh/CN=mesh-1.mesh.local"
# Sign with the CA
openssl x509 -req -in servers/mesh-1.csr -CA ca/certs/ca.crt -CAkey ca/private/ca.key \
-CAcreateserial -out servers/mesh-1.crt -days 365 -sha256 \
-extfile <(printf "subjectAltName=DNS:mesh-1.mesh.local,IP:10.0.0.1")We include the WireGuard IP as a SAN so clients can verify by IP if needed.
Step 3: Distribute Trust
Ansible copies ca.crt to /usr/local/share/ca-certificates/ on each node and runs update-ca-certificates. Each server's key and cert land in /etc/ssl/private/ (mode 600).
- name: Install RiNET root CA
copy:
src: ca.crt
dest: /usr/local/share/ca-certificates/rinet-ca.crt
notify: update ca certificates
- name: Deploy server certificate
copy:
src: "servers/{{ inventory_hostname }}.crt"
dest: /etc/ssl/certs/rinet-server.crt
mode: 0644
- name: Deploy server key
copy:
src: "servers/{{ inventory_hostname }}.key"
dest: /etc/ssl/private/rinet-server.key
mode: 0600Revocation Without CRLs
Public CAs rely on CRLs or OCSP. On a small mesh, we handle revocation by removing the compromised key from the Ansible vault and redeploying. If a server is decommissioned, we regenerate the root CA and re-issue all certs—a 10-minute procedure because we have only three nodes. No CRL distribution points, no OCSP responders.
What We Lost and Gained
Lost:
- Browser trust out of the box (irrelevant—no browser hits our internal endpoints).
- Automatic renewal (we now rotate certs yearly via Ansible; renewal is a manual playbook run).
- Public key pinning via Certificate Transparency (we don't need it; we pin the root CA in our configs).
Gained:
- Zero external dependencies for TLS. No DNS challenges, no port 80, no certbot.
- Full control over validity periods (we use 1 year, but could do 10).
- Simplified logging: every certificate is logged by our internal CA serial number, no third-party logs.
- No renewal race conditions during network partitions.
Practical Considerations
Certificate Rotation
We set up an Ansible playbook that runs openssl commands on the management host and deploys new certs. The playbook also restarts services (vLLM, Qdrant, etc.) gracefully. Since all services use the same root CA, we can rotate the root CA first, then re-issue server certs without downtime.
Trust Distribution for Clients
Our client machines (e.g., laptops connecting via WireGuard) get the root CA certificate via a separate Ansible playbook. We also embed it in our container images for services that run in Docker. For example, the Qwen inference container's Dockerfile includes:
COPY rinet-ca.crt /usr/local/share/ca-certificates/
RUN update-ca-certificatesWhat About Mutual TLS?
We considered mTLS for internal service-to-service auth, but for now, WireGuard provides transport-layer authentication. TLS is used only for HTTP APIs (e.g., vLLM's OpenAI-compatible endpoint). If we ever need mTLS, the same CA can issue client certificates.
When You Should Not Do This
Self-signed CAs are not a universal replacement. If your services are accessed by external users, browsers, or third-party tools that don't trust your CA, stick with Let's Encrypt. Also, if you have dozens or hundreds of nodes, manual re-issuance becomes painful—you'd want an internal PKI like step-ca or Vault.
But for a small, private mesh with known clients and no public exposure, the simplicity of a self-signed CA is liberating. No renewal anxiety, no external trust, no dependency on the Let's Encrypt ecosystem. Just a key, a cert, and a WireGuard link.
The Bottom Line
We replaced Let's Encrypt because our mesh is private, our clients are known, and our tolerance for external dependencies is near zero. The three Hetzner servers that run RiNET's stack now speak TLS over WireGuard with certificates signed by a key that never leaves our management host. It's not for everyone, but for a sovereign stack, it's one less thing to outsource.