Step CA: Build a Private Certificate Authority
Setting up a private Certificate Authority (CA) can feel like stepping into a secret club—once you’re in, you control the trust fabric of your own network. In this guide we’ll walk through the entire lifecycle of a private CA using open‑source tools and a little Python glue. By the end, you’ll be able to spin up a root CA, create an intermediate, and issue certificates for internal services, IoT devices, or CI pipelines—without ever relying on a third‑party CA.
Why a Private CA?
A private CA gives you full control over certificate policies, lifetimes, and revocation. It’s especially handy for:
- Securing micro‑service traffic inside a Kubernetes cluster.
- Signing firmware or device certificates in an IoT deployment.
- Providing TLS for internal development environments where public CA costs add up.
Because you own the root, you can enforce stricter constraints—like mandatory SAN entries, custom extensions, or short‑lived certs—while keeping the overhead low.
Prerequisites
Before we dive in, make sure you have the following installed on your workstation or CI runner:
- OpenSSL (≥ 1.1.1).
- Python 3.8+ (we’ll use
subprocessto call OpenSSL). - A non‑privileged user account—never run a CA as
root.
We’ll keep all files under a single directory called myca. This isolation makes it easy to back up or rotate the CA later.
Step 1: Create the Root CA
Directory layout
First, set up the folder structure that mirrors the best practices from the OpenSSL PKI tutorial.
import os
BASE_DIR = "myca"
subdirs = [
"certs", "crl", "newcerts", "private",
"csr"
]
for sub in subdirs:
os.makedirs(os.path.join(BASE_DIR, sub), exist_ok=True)
# Touch required files
open(os.path.join(BASE_DIR, "index.txt"), "a").close()
open(os.path.join(BASE_DIR, "serial"), "w").write("1000")
This script creates:
certs/– issued certificates.private/– the root key (keep it secret).csr/– certificate signing requests.
Generate the root private key
We’ll use a 4096‑bit RSA key, encrypted with a strong passphrase. Store the passphrase in a safe place—losing it means losing the CA.
import subprocess, getpass, pathlib
root_key = pathlib.Path(BASE_DIR) / "private" / "root.key.pem"
passphrase = getpass.getpass("Enter root key passphrase: ")
subprocess.run([
"openssl", "genrsa",
"-aes256",
"-out", str(root_key),
"4096"
], check=True)
Pro tip: For automated pipelines, consider using a hardware security module (HSM) or a secret manager to store the passphrase securely.
Self‑sign the root certificate
The root certificate is self‑signed and typically valid for many years (10‑20). Here’s a minimal OpenSSL config snippet that defines the basic constraints for a root CA.
import textwrap, pathlib
config = textwrap.dedent("""\
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = {base}
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
private_key = $dir/private/root.key.pem
certificate = $dir/certs/root.cert.pem
default_md = sha256
default_days = 3650
preserve = no
policy = policy_strict
[ policy_strict ]
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[ req_distinguished_name ]
CN = My Private Root CA
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
""".format(base=BASE_DIR))
conf_path = pathlib.Path(BASE_DIR) / "openssl.cnf"
conf_path.write_text(config)
Now create the self‑signed root certificate:
subprocess.run([
"openssl", "req",
"-config", str(conf_path),
"-key", str(root_key),
"-new", "-x509",
"-days", "3650",
"-sha256",
"-extensions", "v3_ca",
"-out", str(pathlib.Path(BASE_DIR) / "certs" / "root.cert.pem")
], check=True)
Step 2: Build an Intermediate CA
Why an intermediate?
Best practice dictates that the root key never signs end‑entity certificates. Instead, you create one or more intermediate CAs that handle day‑to‑day signing. If an intermediate is compromised, you can revoke it without touching the root.
Generate the intermediate key and CSR
intermediate_key = pathlib.Path(BASE_DIR) / "private" / "intermediate.key.pem"
subprocess.run([
"openssl", "genrsa",
"-aes256",
"-out", str(intermediate_key),
"4096"
], check=True)
intermediate_csr = pathlib.Path(BASE_DIR) / "csr" / "intermediate.csr.pem"
subprocess.run([
"openssl", "req",
"-new", "-sha256",
"-key", str(intermediate_key),
"-out", str(intermediate_csr),
"-subj", "/CN=My Intermediate CA"
], check=True)
Sign the intermediate with the root
We reuse the same OpenSSL config, but now we point to the intermediate’s extensions.
# Append intermediate extensions to the config
with open(conf_path, "a") as f:
f.write(textwrap.dedent("""\
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
"""))
intermediate_cert = pathlib.Path(BASE_DIR) / "certs" / "intermediate.cert.pem"
subprocess.run([
"openssl", "ca",
"-config", str(conf_path),
"-extensions", "v3_intermediate_ca",
"-days", "3650",
"-notext",
"-md", "sha256",
"-batch",
"-in", str(intermediate_csr),
"-out", str(intermediate_cert)
], check=True)
Now create a certificate chain file that bundles the intermediate and root together. This chain will be served to clients.
chain_path = pathlib.Path(BASE_DIR) / "certs" / "ca-chain.cert.pem"
with open(chain_path, "w") as out:
out.write(open(intermediate_cert).read())
out.write(open(pathlib.Path(BASE_DIR) / "certs" / "root.cert.pem").read())
Step 3: Issue End‑Entity Certificates
Typical use case: TLS for an internal API
Let’s say you have a micro‑service called inventory that needs a certificate for mutual TLS. We’ll generate a key, a CSR, and then sign it with the intermediate CA.
service_key = pathlib.Path(BASE_DIR) / "private" / "inventory.key.pem"
subprocess.run([
"openssl", "genrsa",
"-out", str(service_key),
"2048"
], check=True)
service_csr = pathlib.Path(BASE_DIR) / "csr" / "inventory.csr.pem"
subprocess.run([
"openssl", "req",
"-new",
"-key", str(service_key),
"-out", str(service_csr),
"-subj", "/CN=inventory.internal"
], check=True)
Signing the service certificate
We’ll use a short lifetime (90 days) to encourage frequent rotation, a practice that improves security.
# Add server extensions to the config
with open(conf_path, "a") as f:
f.write(textwrap.dedent("""\
[ server_cert ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = inventory.internal
DNS.2 = inventory
"""))
service_cert = pathlib.Path(BASE_DIR) / "certs" / "inventory.cert.pem"
subprocess.run([
"openssl", "ca",
"-config", str(conf_path),
"-extensions", "server_cert",
"-days", "90",
"-notext",
"-md", "sha256",
"-batch",
"-in", str(service_csr),
"-out", str(service_cert)
], check=True)
The resulting inventory.cert.pem can be mounted into a Kubernetes secret or copied to a VM. Pair it with inventory.key.pem and the ca-chain.cert.pem to complete the TLS handshake.
Step 4: Managing Revocation
Even the best‑planned PKI needs a way to invalidate compromised or expired certificates. OpenSSL provides a Certificate Revocation List (CRL) that clients can check.
# Revoke a certificate (example: inventory)
subprocess.run([
"openssl", "ca",
"-config", str(conf_path),
"-revoke", str(service_cert)
], check=True)
# Generate an updated CRL
crl_path = pathlib.Path(BASE_DIR) / "crl" / "ca.crl.pem"
subprocess.run([
"openssl", "ca",
"-config", str(conf_path),
"-gencrl",
"-out", str(crl_path)
], check=True)
Distribute the CRL via an HTTP endpoint or embed it in your service mesh. Most modern TLS libraries support OCSP stapling, which can be added later for real‑time revocation checks.
Pro tip: Automate CRL generation with a cron job (e.g., every 12 hours) and publish it to /etc/ssl/crl/ca.crl.pem on every host that validates client certs.
Real‑World Scenarios
1. Secure CI/CD pipelines
When your build agents need to pull artifacts from an internal Nexus or Artifactory, you can issue a client certificate signed by the intermediate. The repository validates the client cert against the CA chain, eliminating password‑based auth.
2. IoT device provisioning
Manufacturers often embed a unique certificate per device during production. Using a private CA lets you keep the provisioning process offline, yet still enforce mutual TLS when devices talk to the cloud gateway.
3. Multi‑tenant SaaS platforms
Each tenant receives its own sub‑CA (derived from the same root). This isolates tenant certificates while allowing the platform to rotate the root once per year without breaking every tenant’s trust store.
Automation with Python – Full PKI Script
Below is a compact, end‑to‑end script that ties everything together. It’s deliberately verbose to illustrate each step, but you can trim it for production pipelines.
#!/usr/bin/env python3
import os, subprocess, pathlib, getpass, textwrap
BASE = pathlib.Path("myca")
def run(cmd):
subprocess.run(cmd, check=True)
def init_dirs():
for sub in ["certs","crl","newcerts","private","csr"]:
(BASE/sub).mkdir(parents=True, exist_ok=True)
(BASE/"index.txt").write_text("")
(BASE/"serial").write_text("1000")
def write_config():
cfg = textwrap.dedent(f"""\
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = {BASE}
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
private_key = $dir/private/root.key.pem
certificate = $dir/certs/root.cert.pem
default_md = sha256
default_days = 3650
preserve = no
policy = policy_strict
[ policy_strict ]
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[ req_distinguished_name ]
CN = My Private Root CA
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
[ server_cert ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = inventory.internal
DNS.2 = inventory
""")
(BASE/"openssl.cnf").write_text(cfg)
def create_root():
key = BASE/"private"/"root.key.pem"
passphrase = getpass.getpass("Root key passphrase: ")
run(["openssl","genrsa","-aes256","-out",str(key),"4096"])
run([
"openssl","req","-config",str(BASE/"openssl.cnf"),
"-key",str(key),"-new","-x509","-days","3650",
"-sha256","-extensions","v3_ca",
"-out",str(BASE/"certs"/"root.cert.pem")
])
def create_intermediate():
int_key = BASE/"private"/"intermediate.key.pem"
run(["openssl","genrsa","-aes256","-out",str(int_key),"4096"])
int_csr = BASE/"csr"/"intermediate.csr.pem"
run([
"openssl","req","-new","-sha256","-key",str(int_key),
"-out",str(int_csr),"-subj","/CN=My Intermediate CA"
])
int_cert = BASE/"certs"/"intermediate.cert.pem"
run([
"openssl","ca","-config",str(BASE/"openssl.cnf"),
"-extensions","v3_intermediate_ca","-days","3650",
"-notext","-md","sha256","-batch",
"-in",str(int_csr),"-out",str(int_cert)
])
# chain
chain = BASE/"certs"/"ca-chain.cert.pem"
chain.write_text(open(int_cert).read() + open(BASE/"certs"/"root.cert.pem").read())
def issue_server_cert(common_name, san_list):
key = BASE/"private"/f"{common_name}.key.pem"
run(["openssl","genrsa","-out",str(key),"2048"])
csr = BASE/"csr"/f"{common_name}.csr.pem"
run([
"openssl","req","-new","-key",str(key),
"-out",str(csr),"-subj",f"/CN