Step CA: Build a Private Certificate Authority
PROGRAMMING LANGUAGES March 25, 2026, 11:30 a.m.

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:

  1. OpenSSL (≥ 1.1.1).
  2. Python 3.8+ (we’ll use subprocess to call OpenSSL).
  3. 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
        
Share this article