Private CA and troubleshooting
Build a private CA with OpenSSL: root, intermediate, mTLS
A private certificate authority issues certificates for internal services, test environments and mutual TLS without a public CA fee and without exposing internal hostnames in Certificate Transparency logs. The commands below, run with OpenSSL 3.6.1 in an empty directory, build a two-tier CA (offline root, online intermediate) and issue server and client certificates from it. Passwords are inline for brevity.
Prepare the directory and configuration file
openssl ca keeps state in an index file, a serial file and a CRL number file; create them once and describe the CA in a config file.
mkdir -p pki/root pki/ica/newcerts pki/certs && cd pki
touch ica/index.txt && echo 1000 > ica/serial && echo 1000 > ica/crlnumber
chmod 700 root ica
# ca.cnf
[ ca ]
default_ca = intermediate_ca
[ intermediate_ca ]
dir = ./ica
database = $dir/index.txt
serial = $dir/serial
crlnumber = $dir/crlnumber
new_certs_dir = $dir/newcerts
certificate = $dir/ica.crt
private_key = $dir/ica.key
default_md = sha256
default_days = 397
default_crl_days = 7
policy = policy_loose
copy_extensions = copy
unique_subject = no
crl_extensions = crl_ext
[ policy_loose ]
countryName = optional
organizationName = optional
commonName = supplied
[ v3_intermediate ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
nameConstraints = critical, permitted;DNS:example.com, permitted;DNS:.example.com
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
crlDistributionPoints = URI:http://pki.example.com/ica.crl
[ client_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
[ crl_ext ]
authorityKeyIdentifier = keyid:always
$ find . -maxdepth 2
./root
./ica
./ica/newcerts
./ica/serial
./ica/index.txt
./ica/crlnumber
./certs
./ca.cnf
copy_extensions = copy copies extensions such as the SAN from the CSR unless the config already sets them, while server_cert and client_cert fix the security-relevant ones (basic constraints, key usage, EKU). Never use copyall with CSRs you did not create: a requester could smuggle in CA:TRUE.
Create the root CA key
Use an encrypted ECDSA P-384 key and keep it offline once the intermediate is signed.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -aes-256-cbc -out root/root.key
head -1 root/root.key
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:
-----BEGIN ENCRYPTED PRIVATE KEY-----
genpkey writes PKCS#8 and, with a cipher option, encrypts it. RSA 4096 (-algorithm RSA -pkeyopt rsa_keygen_bits:4096) is the conservative alternative if some clients cannot handle ECDSA. More key types are in generating keys.
Self-sign the root certificate
A root needs basicConstraints=critical,CA:TRUE and keyUsage=keyCertSign,cRLSign; ten years is a common lifetime.
openssl req -x509 -new -key root/root.key -sha384 -days 3650 \
-subj "/C=CN/O=Example Corp/CN=Example Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" \
-out root/root.crt
openssl x509 -in root/root.crt -noout -subject -dates -ext basicConstraints,keyUsage
subject=C=CN, O=Example Corp, CN=Example Root CA
notBefore=Nov 9 18:28:37 2026 GMT
notAfter=Nov 6 18:28:37 2036 GMT
X509v3 Basic Constraints: critical
CA:TRUE
X509v3 Key Usage: critical
Certificate Sign, CRL Sign
-addext works with req -x509 since OpenSSL 1.1.1. Choose a distinctive Common Name: it is what administrators see in trust store listings.
Create and sign the intermediate CA
Generate the intermediate key and CSR, then sign it with the root using the v3_intermediate section.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -aes-256-cbc -out ica/ica.key
openssl req -new -key ica/ica.key -subj "/C=CN/O=Example Corp/CN=Example Intermediate CA" -out ica/ica.csr
openssl x509 -req -in ica/ica.csr -CA root/root.crt -CAkey root/root.key -days 1825 -sha384 \
-extfile ca.cnf -extensions v3_intermediate -out ica/ica.crt
openssl x509 -in ica/ica.crt -noout -subject -issuer -ext basicConstraints,nameConstraints
Certificate request self-signature ok
subject=C=CN, O=Example Corp, CN=Example Intermediate CA
subject=C=CN, O=Example Corp, CN=Example Intermediate CA
issuer=C=CN, O=Example Corp, CN=Example Root CA
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:0
X509v3 Name Constraints: critical
Permitted:
DNS:example.com
DNS:.example.com
pathlen:0 means this intermediate may issue end-entity certificates but no further CAs. After this step, move root/root.key to offline storage; you only need it again to sign a new intermediate or a root CRL.
Issue a server certificate with openssl ca
openssl ca records every certificate in index.txt, which you need later for revocation and CRLs.
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
-keyout certs/www.key -subj "/CN=www.example.com" \
-addext "subjectAltName=DNS:www.example.com,DNS:example.com" -out certs/www.csr
openssl ca -config ca.cnf -extensions server_cert -in certs/www.csr -out certs/www.crt -notext -batch
openssl x509 -in certs/www.crt -noout -ext subjectAltName,extendedKeyUsage
Using configuration from ca.cnf
Enter pass phrase for ./ica/ica.key:
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
commonName :ASN.1 12:'www.example.com'
Certificate is to be certified until Dec 11 18:29:09 2027 GMT (397 days)
Write out database with 1 new entries
Database updated
X509v3 Extended Key Usage:
TLS Web Server Authentication
X509v3 Subject Alternative Name:
DNS:www.example.com, DNS:example.com
-noenc (OpenSSL 3.0+) replaces the deprecated -nodes and leaves the server key unencrypted so a web server can load it. -notext stops openssl ca from writing a human-readable dump above the PEM block. Clients check only the SAN, so always include every hostname there; CSR options are covered in creating CSRs.
Sign a CSR without a CA database
openssl x509 -req -CA signs directly; add -copy_extensions copyall or the SAN from the CSR is lost.
openssl x509 -req -in certs/api.csr -CA ica/ica.crt -CAkey ica/ica.key -CAcreateserial -days 90 \
-copy_extensions copyall -extfile ca.cnf -extensions server_cert -out certs/api.crt
openssl x509 -in certs/api.crt -noout -serial -ext subjectAltName
Certificate request self-signature ok
subject=CN=api.example.com
serial=06AE478770F418A3316C8ACA2EA61D012B75A715
X509v3 Subject Alternative Name:
DNS:api.example.com
-copy_extensions for x509 and req arrived in OpenSSL 3.0; on 1.1.1 put the SAN into the -extfile section instead. Use copyall only for CSRs you created yourself, because it copies every extension the requester asked for. Certificates issued this way are not in index.txt, so openssl ca -revoke knows nothing about them.
Issue a client certificate for mutual TLS
Client certificates use the clientAuth EKU and usually identify a user or service in the subject.
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
-keyout certs/alice.key -subj "/O=Example Corp/CN=alice" -out certs/alice.csr
openssl ca -config ca.cnf -extensions client_cert -days 365 -in certs/alice.csr -out certs/alice.crt -notext -batch
openssl x509 -in certs/alice.crt -noout -subject -ext extendedKeyUsage
Write out database with 1 new entries
Database updated
subject=O=Example Corp, CN=alice
X509v3 Extended Key Usage:
TLS Web Client Authentication
Public CAs are removing clientAuth: the Chrome Root Program began phasing clientAuth out of publicly trusted TLS hierarchies on 2026-06-15 (subscriber certificates issued from 2027-03-15 must assert only serverAuth), and Let's Encrypt's Generation Y certificates no longer include it. A private CA is the right source for mTLS client certificates; see mTLS explained and client auth EKU removed.
Build the chain file and verify it
Servers send the leaf plus the intermediate; clients trust the root.
cat certs/www.crt ica/ica.crt > certs/www.fullchain.pem
openssl verify -show_chain -CAfile root/root.crt -untrusted ica/ica.crt -purpose sslserver certs/www.crt
openssl verify -CAfile root/root.crt -untrusted ica/ica.crt -purpose sslclient certs/alice.crt
certs/www.crt: OK
Chain:
depth=0: CN=www.example.com (untrusted)
depth=1: C=CN, O=Example Corp, CN=Example Intermediate CA (untrusted)
depth=2: C=CN, O=Example Corp, CN=Example Root CA
certs/alice.crt: OK
Use www.fullchain.pem with Nginx ssl_certificate. For an mTLS server, point the client-CA setting (Nginx ssl_client_certificate, ssl_verify_client on) at the intermediate and root. More checks are in verifying chains.
Package a client certificate as PKCS#12
Browsers, phones and Windows import client certificates as a password-protected .p12 file.
openssl pkcs12 -export -in certs/alice.crt -inkey certs/alice.key -certfile ica/ica.crt \
-name "alice@example.com" -out certs/alice.p12
openssl pkcs12 -in certs/alice.p12 -info -noout | head -3
Enter Export Password:
Verifying - Enter Export Password:
MAC: sha256, Iteration 2048
MAC length: 32, salt length: 16
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
Since OpenSSL 3.0 the default is AES-256-CBC with PBKDF2 and a SHA-256 MAC. Older Windows releases, old Java versions and some embedded devices cannot read that; for them add -legacy (RC2/3DES with SHA-1 MAC) or -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1. Details in PKCS#12 commands.
Restrict the CA to your own domains
The nameConstraints in the intermediate make any certificate for another domain fail verification, even though the CA signed it.
openssl ca -config ca.cnf -extensions server_cert -in certs/shop.csr -out certs/shop.crt -notext -batch
openssl verify -CAfile root/root.crt -untrusted ica/ica.crt certs/shop.crt
CN=shop.example.net
error 47 at 0 depth lookup: permitted subtree violation
error certs/shop.crt: verification failed
Name constraints limit the damage if the intermediate key leaks, and they make it easier for other teams to agree to trust your root. permitted;DNS:.example.com matches subdomains; add permitted;DNS:example.com for the apex and permitted;IP:10.0.0.0/255.0.0.0 for internal IP addresses. Mark the extension critical so clients that do not understand it reject the certificate rather than ignore the constraint.
Revoke a certificate and publish a CRL
openssl ca -revoke marks a certificate in index.txt; -gencrl writes the signed CRL that clients download.
openssl ca -config ca.cnf -revoke certs/alice.crt -crl_reason keyCompromise
openssl ca -config ca.cnf -gencrl -out ica/ica.crl
openssl verify -crl_check -CAfile root/root.crt -untrusted ica/ica.crt -CRLfile ica/ica.crl certs/alice.crt
Using configuration from ca.cnf
Revoking Certificate 1001.
Database updated
Using configuration from ca.cnf
O=Example Corp, CN=alice
error 23 at 0 depth lookup: certificate revoked
error certs/alice.crt: verification failed
default_crl_days = 7 means the CRL expires after a week, so regenerate it from cron and publish it at the URL in crlDistributionPoints. Nginx checks client certificates against a CRL with ssl_crl. For an OCSP test responder see OCSP and CRL commands.
Trust the root on Linux, macOS, Windows and Java
Each platform has its own trust store; add only the root, never the intermediate.
# Debian / Ubuntu (file must end in .crt)
sudo cp root/root.crt /usr/local/share/ca-certificates/example-root-ca.crt && sudo update-ca-certificates
# RHEL / Rocky / Alma
sudo cp root/root.crt /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust
# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain root/root.crt
# Windows (elevated prompt)
certutil -addstore -f Root root.crt
# Java (cacerts of the running JDK)
keytool -importcert -cacerts -alias example-root-ca -file root/root.crt -noprompt -storepass changeit
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.
Certificate was added to keystore
Firefox uses its own store unless the security.enterprise_roots.enabled preference or an enterprise policy imports system roots. Containers need the root baked into the image. Python requests uses the certifi bundle by default, so point REQUESTS_CA_BUNDLE at your root, or you will see CERTIFICATE_VERIFY_FAILED. Keep server certificates at or below 825 days for Apple clients, and prefer much shorter lifetimes with automated renewal.
FAQ
Why not use a self-signed certificate instead?
A self-signed certificate must be trusted individually on every client, and replacing it means touching every client again. A private CA is trusted once; after that you can issue, rotate and revoke as many certificates as you like. For a single test host, a self-signed certificate is fine.
Can I use a private CA for a public website?
No. Browsers of ordinary visitors do not trust your root, so they would see NET::ERR_CERT_AUTHORITY_INVALID. Private CAs are for internal hosts, service-to-service mTLS, VPNs, devices and test environments.
Do the public 200-day and 47-day validity limits apply to my private CA?
No, the CA/Browser Forum Baseline Requirements only bind publicly trusted CAs. Clients still impose their own rules: Apple platforms cap server certificates at 825 days. Short lifetimes with automated renewal are good practice internally too.