Building Your Own Certificate Authority with OpenSSL

There’s a point in most sysadmin careers where you stop relying entirely on public CAs and start thinking about internal infrastructure. Internal APIs, staging environments, service-to-service TLS, developer machines — all of these benefit from having a proper internal CA rather than a pile of self-signed certificates scattered everywhere.

I set up my first internal CA a few years back and it’s made managing certificates across internal infrastructure much cleaner. Here’s how I do it, with the commands I actually use.

Why a Private CA?

Self-signed certificates are fine for one-offs. But when you have 20 internal services all using self-signed certs, you end up adding exceptions everywhere — browsers, curl, Java keystores, Python requests. It’s a mess.

With a private CA, you add the CA root to your trust store once (browser, OS, whatever), and then every certificate signed by that CA is automatically trusted. Clean, consistent, and easy to revoke when needed.

The tradeoff: you’re now responsible for protecting the CA private key. If that key is compromised, every certificate signed by it is compromised. I keep CA private keys on encrypted storage, offline when not in use.

Directory Structure

I use this layout:

/root/ca/
├── certs/          # CA certificates
├── crl/            # Certificate revocation lists
├── newcerts/       # Copies of all issued certs
├── private/        # CA private keys (chmod 700)
├── index.txt       # Certificate database
├── serial          # Next certificate serial number
└── openssl.cnf     # CA configuration

Create it:

mkdir -p /root/ca/{certs,crl,newcerts,private}
chmod 700 /root/ca/private
touch /root/ca/index.txt
echo 1000 > /root/ca/serial

OpenSSL Configuration File

Create /root/ca/openssl.cnf:

[ ca ]
default_ca = CA_default

[ CA_default ]
dir               = /root/ca
certs             = $dir/certs
crl_dir           = $dir/crl
new_certs_dir     = $dir/newcerts
database          = $dir/index.txt
serial            = $dir/serial
RANDFILE          = $dir/private/.rand

private_key       = $dir/private/ca.key.pem
certificate       = $dir/certs/ca.cert.pem

crl               = $dir/crl/ca.crl.pem
crlnumber         = $dir/crlnumber
crl_extensions    = crl_ext
default_crl_days  = 30

default_md        = sha256
name_opt          = ca_default
cert_opt          = ca_default
default_days      = 375
preserve          = no
policy            = policy_strict

[ policy_strict ]
countryName             = match
stateOrProvinceName     = match
organizationName        = match
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ policy_loose ]
countryName             = optional
stateOrProvinceName     = optional
localityName            = optional
organizationName        = optional
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ req ]
default_bits        = 4096
distinguished_name  = req_distinguished_name
string_mask         = utf8only
default_md          = sha256
x509_extensions     = v3_ca

[ req_distinguished_name ]
countryName                     = Country Name (2 letter code)
stateOrProvinceName             = State or Province Name
localityName                    = Locality Name
0.organizationName              = Organization Name
organizationalUnitName          = Organizational Unit Name
commonName                      = Common Name
emailAddress                    = Email Address

[ 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

[ usr_cert ]
basicConstraints = CA:FALSE
nsCertType = client, email
nsComment = "OpenSSL Generated Client Certificate"
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, emailProtection

[ server_cert ]
basicConstraints = CA:FALSE
nsCertType = server
nsComment = "OpenSSL Generated Server Certificate"
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth

[ crl_ext ]
authorityKeyIdentifier=keyid:always

[ ocsp ]
basicConstraints = CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = critical, OCSPSigning

Creating the Root CA Key and Certificate

cd /root/ca

# Generate the root key (4096-bit RSA, encrypted with AES-256)
openssl genrsa -aes256 -out private/ca.key.pem 4096
chmod 400 private/ca.key.pem

# Create the root certificate (valid 20 years)
openssl req -config openssl.cnf \
    -key private/ca.key.pem \
    -new -x509 -days 7300 -sha256 -extensions v3_ca \
    -out certs/ca.cert.pem
chmod 444 certs/ca.cert.pem

The root certificate gets a long validity — 10–20 years is typical. The private key should be passphrase-protected and stored offline after setup. You only need the root CA to sign intermediate CAs, not to issue server certs day-to-day.

Verify what you created:

openssl x509 -noout -text -in certs/ca.cert.pem

Using an Intermediate CA

For any real deployment, add an intermediate CA between the root and your server certificates. If the intermediate CA key is compromised, you can revoke it and issue a new one without touching the root. The root key can stay offline most of the time.

mkdir -p /root/ca/intermediate/{certs,crl,csr,newcerts,private}
chmod 700 /root/ca/intermediate/private
touch /root/ca/intermediate/index.txt
echo 1000 > /root/ca/intermediate/serial
echo 1000 > /root/ca/intermediate/crlnumber

Create an intermediate CA config (/root/ca/intermediate/openssl.cnf) — same as the root config but with dir = /root/ca/intermediate and using policy_loose instead of policy_strict.

Generate the intermediate key and CSR:

openssl genrsa -aes256 -out intermediate/private/intermediate.key.pem 4096

openssl req -config intermediate/openssl.cnf -new -sha256 \
    -key intermediate/private/intermediate.key.pem \
    -out intermediate/csr/intermediate.csr.pem

Sign the intermediate CSR with the root CA:

openssl ca -config openssl.cnf -extensions v3_intermediate_ca \
    -days 3650 -notext -md sha256 \
    -in intermediate/csr/intermediate.csr.pem \
    -out intermediate/certs/intermediate.cert.pem

Create the chain file:

cat intermediate/certs/intermediate.cert.pem \
    certs/ca.cert.pem > intermediate/certs/ca-chain.cert.pem

Issuing Server Certificates

Now for the actual day-to-day work — signing certificates for your services.

On the server that needs a certificate, generate a key and CSR:

openssl genrsa -out private/api.internal.key.pem 2048

openssl req -new -sha256 \
    -key private/api.internal.key.pem \
    -out csr/api.internal.csr.pem \
    -subj "/C=US/ST=California/O=MyCompany/CN=api.internal"

If you need Subject Alternative Names (SANs), add them in an extensions file:

cat > /tmp/san.conf << EOF
[SAN]
subjectAltName = DNS:api.internal, DNS:api.service.local, IP:192.168.1.10
EOF

openssl req -new -sha256 \
    -key private/api.internal.key.pem \
    -out csr/api.internal.csr.pem \
    -reqexts SAN \
    -config <(cat /root/ca/intermediate/openssl.cnf /tmp/san.conf) \
    -subj "/C=US/ST=California/O=MyCompany/CN=api.internal"

Sign it with the intermediate CA:

openssl ca -config /root/ca/intermediate/openssl.cnf \
    -extensions server_cert -days 375 -notext -md sha256 \
    -in csr/api.internal.csr.pem \
    -out certs/api.internal.cert.pem

Now you have a certificate signed by your intermediate CA, which is signed by your root CA.

Distributing the Root CA

For internal tools to trust certificates signed by your CA, distribute and install the root certificate:

Ubuntu/Debian:

cp /root/ca/certs/ca.cert.pem /usr/local/share/ca-certificates/mycompany-ca.crt
update-ca-certificates

CentOS/RHEL:

cp /root/ca/certs/ca.cert.pem /etc/pki/ca-trust/source/anchors/mycompany-ca.crt
update-ca-trust

macOS: Import into Keychain Access and set to “Always Trust”.

Windows: Import into the Trusted Root Certification Authorities store via MMC.

Once the root is installed, curl, browsers, Python requests, and Java (with its own keystore) will all trust certificates from your CA without any custom configuration.

Certificate Revocation

When a certificate is compromised or no longer needed, revoke it:

openssl ca -config /root/ca/intermediate/openssl.cnf \
    -revoke intermediate/newcerts/1001.pem

Then regenerate the CRL:

openssl ca -config /root/ca/intermediate/openssl.cnf \
    -gencrl -out intermediate/crl/intermediate.crl.pem

Distribute this CRL to systems that need to check revocation. If you’re using OCSP, that’s a more complex setup but gives real-time revocation checking.

Keeping It Organized

I keep a simple log of what I’ve issued — domain name, serial number, expiry date, and what service it’s for. The index.txt file OpenSSL maintains is a database of issued certs, but plain text notes help when I’m troubleshooting something at 2am and need to know which cert goes where.

Also: set calendar reminders for certificate expiry. 375-day server certs mean you’re renewing roughly annually. The root and intermediate CA certs are long-lived, but you’ll still want reminders for those.

For the server configuration side, the Nginx SSL configuration guide and the SSL certificate chain explainer are both useful once you’ve issued your certs and need to configure your web server properly.

Scroll to Top