Public CAs such as Let's Encrypt can only issue certificates for names that resolve on the public Internet. Internal services (an admin panel on a private network, a database, a service reached as app.internal.example or by a private IP) still need TLS, and a single self-signed certificate per service quickly becomes unmanageable because every client has to trust every certificate separately. In this tutorial you will create a small private certificate authority (CA) with OpenSSL on Ubuntu 24.04, issue a server certificate with the right Subject Alternative Names, trust the CA on your machines, serve it with Nginx, and optionally require client certificates.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user that has
sudoprivileges. OpenSSL 3.0 is installed by default. - Nginx installed on the server that will host the internal service (
sudo apt install nginx). - An internal name for the service. This guide uses
app.internal.exampleand the private address10.0.0.10; replace them with your own. The name must resolve on your clients, through internal DNS or/etc/hosts.
ImportantThe CA private key can sign a certificate for any name, including public ones, and every machine that trusts your CA will accept it. Keep the CA on a machine with restricted access (ideally not the web server itself), protect the key with a passphrase, and back it up offline.
Self-signed certificate or private CA?
For a one-off test, a single self-signed certificate is enough:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
-keyout test.key -out test.crt -days 365 \
-subj "/CN=test.internal" -addext "subjectAltName=DNS:test.internal"
Each client must then trust that exact certificate, and has to be updated every time it changes. A private CA solves this: clients trust the CA once, and every certificate it signs is accepted automatically. The rest of this guide builds that setup.
Step 1 - Creating the CA directory and key
Create a directory for the CA that only your user can read:
mkdir -p ~/internal-ca
chmod 700 ~/internal-ca
cd ~/internal-ca
Generate a 4096-bit RSA key for the root CA, encrypted with AES-256. OpenSSL asks for a passphrase; choose a strong one and store it in your password manager:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -aes256 -out ca.key
chmod 600 ca.key
Check that the key is encrypted:
head -1 ca.key
-----BEGIN ENCRYPTED PRIVATE KEY-----
Step 2 - Creating the root CA certificate
A CA certificate needs specific extensions: CA:TRUE so clients accept it as an issuer, and a key usage limited to signing certificates and CRLs. Put them in a small configuration file:
nano ~/internal-ca/ca.cnf
[ req ]
distinguished_name = dn
prompt = no
x509_extensions = v3_ca
[ dn ]
O = Example Internal
CN = Example Internal Root CA
[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
Change O and CN to your organization's name. pathlen:0 means this CA can sign end-entity certificates but not other CAs.
Create the self-signed root certificate, valid for 10 years:
openssl req -x509 -new -config ca.cnf -key ca.key -sha256 -days 3650 -out ca.crt
Verify its subject, expiry and extensions:
openssl x509 -in ca.crt -noout -subject -enddate -ext basicConstraints,keyUsage
subject=O = Example Internal, CN = Example Internal Root CA
notAfter=Sep 22 12:17:35 2036 GMT
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:0
X509v3 Key Usage: critical
Certificate Sign, CRL Sign
ca.crt is public: it is the file you distribute to clients. ca.key never leaves this directory.
Step 3 - Issuing a server certificate
Modern clients ignore the Common Name and only check the Subject Alternative Name (SAN) extension, so every name and IP address a client will use to reach the service must be listed there.
Generate a key for the service. An ECDSA P-256 key is small, fast and supported everywhere that matters:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out app.key
chmod 600 app.key
Create a certificate signing request (CSR):
openssl req -new -key app.key -subj "/CN=app.internal.example" -out app.csr
Write the extensions for a server certificate:
nano ~/internal-ca/app.ext
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:app.internal.example, IP:10.0.0.10
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
Sign the CSR with the CA. The validity of 397 days matches what browsers accept for public certificates; -CAcreateserial creates ca.srl to track serial numbers:
openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 397 -sha256 -extfile app.ext -out app.crt
Enter the CA passphrase when prompted, then check the result:
openssl verify -CAfile ca.crt app.crt
openssl x509 -in app.crt -noout -subject -issuer -enddate -ext subjectAltName
app.crt: OK
subject=CN = app.internal.example
issuer=O = Example Internal, CN = Example Internal Root CA
notAfter=Oct 27 12:17:42 2027 GMT
X509v3 Subject Alternative Name:
DNS:app.internal.example, IP Address:10.0.0.10
Repeat this step for each service, with its own key, CSR and .ext file. For the next certificates, replace -CAcreateserial with -CAserial ca.srl.
Step 4 - Serving the certificate with Nginx
Copy the certificate and key to the server that runs the service (for example with scp), then place them in a dedicated directory. The key must be readable by root only; Nginx reads it before dropping privileges:
sudo mkdir -p /etc/ssl/internal
sudo install -m 644 app.crt /etc/ssl/internal/app.crt
sudo install -m 600 app.key /etc/ssl/internal/app.key
Create a server block:
sudo nano /etc/nginx/sites-available/app.internal.example
server {
listen 443 ssl http2;
server_name app.internal.example;
ssl_certificate /etc/ssl/internal/app.crt;
ssl_certificate_key /etc/ssl/internal/app.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
}
Replace 127.0.0.1:8080 with the address of your application. The http2 parameter on listen is correct for Nginx 1.24, the version in Ubuntu 24.04; from Nginx 1.25.1 onward it is replaced by a separate http2 on; directive.
Enable the site, test the configuration and reload:
sudo ln -s /etc/nginx/sites-available/app.internal.example /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Because the root CA signed the certificate directly, there is no intermediate to append: app.crt alone is the complete chain.
Step 5 - Trusting the CA on clients
Each machine that connects to the service must trust ca.crt.
On Ubuntu and Debian, copy it into the local CA directory. The file name must end in .crt:
sudo cp ca.crt /usr/local/share/ca-certificates/example-internal-ca.crt
sudo update-ca-certificates
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.
On Rocky Linux, AlmaLinux and RHEL, the equivalent is:
sudo cp ca.crt /etc/pki/ca-trust/source/anchors/example-internal-ca.crt
sudo update-ca-trust
Now test from the client. curl uses the system store, so no extra option is needed:
curl -sS -o /dev/null -w '%{http_code}\n' https://app.internal.example/
200
A 502 here only means your backend is not running; the TLS handshake still succeeded. If the CA were not trusted, you would get curl: (60) SSL certificate problem: unable to get local issuer certificate.
Some runtimes ship their own CA bundle and ignore the system store:
- Python
requestsuses thecertifibundle. Point it at the system bundle withexport REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. - Node.js uses its built-in list. Add your CA with
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/example-internal-ca.crt. - Java uses its own
cacertskeystore. On Ubuntu, theca-certificates-javapackage imports the system store into it when you runupdate-ca-certificates; elsewhere, usesudo keytool -importcert -cacerts -alias example-internal-ca -file ca.crt. - Desktop browsers may use their own store (Firefox always does). Import
ca.crtas an authority in the browser's certificate settings.
Step 6 - Requiring client certificates (optional)
The same CA can issue certificates that identify clients, so only machines holding one can reach the service (mutual TLS). On the CA machine, create a key and CSR for the client, named after what it is:
cd ~/internal-ca
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out backup-job.key
openssl req -new -key backup-job.key -subj "/CN=backup-job" -out backup-job.csr
Client certificates need the clientAuth extended key usage:
nano ~/internal-ca/client.ext
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
Sign it and verify it is valid for client use:
openssl x509 -req -in backup-job.csr -CA ca.crt -CAkey ca.key -CAserial ca.srl \
-days 397 -sha256 -extfile client.ext -out backup-job.crt
openssl verify -CAfile ca.crt -purpose sslclient backup-job.crt
backup-job.crt: OK
On the Nginx server, copy ca.crt to /etc/ssl/internal/ca.crt and add these lines inside the server block from Step 4:
ssl_client_certificate /etc/ssl/internal/ca.crt;
ssl_verify_client on;
Test and reload with sudo nginx -t && sudo systemctl reload nginx. A request without a certificate is now rejected with 400 No required SSL certificate was sent, while a client presenting one succeeds:
curl -sS -o /dev/null -w '%{http_code}\n' \
--cert backup-job.crt --key backup-job.key https://app.internal.example/
200
Your application can read the client's identity from the $ssl_client_s_dn variable, for example with proxy_set_header X-Client-DN $ssl_client_s_dn;.
Step 7 - Renewing certificates before they expire
Nothing renews these certificates for you. Check how long a certificate has left; -checkend takes seconds, so this tests the next 30 days:
openssl x509 -in /etc/ssl/internal/app.crt -noout -enddate -checkend 2592000
notAfter=Oct 27 12:17:42 2027 GMT
Certificate will not expire
To renew, repeat Step 3 with the existing CSR (or a new key and CSR, which is better practice), copy the new app.crt into place and run sudo systemctl reload nginx. Put the expiry dates in your monitoring or calendar; the root CA itself expires after 10 years, and replacing it means redistributing a new ca.crt to every client.
Troubleshooting
curl: (60) ... unable to get local issuer certificate. The client does not trust the CA. Repeat Step 5 on that client, and make sure the file copied to /usr/local/share/ca-certificates/ ends in .crt.
no alternative certificate subject name matches target host name. The name or IP in the URL is not in the SAN list. Reissue the certificate with the missing entry in subjectAltName.
Nginx fails with key values mismatch. The certificate and key do not belong together. Compare openssl x509 -in app.crt -noout -pubkey with openssl pkey -in app.key -pubout; the output must be identical.
Java reports PKIX path building failed. The JVM uses a different trust store than the system. Import ca.crt into the cacerts keystore of the JVM that runs the application, then restart it.
Conclusion
You created a private root CA with OpenSSL, issued server and client certificates with correct SANs and key usages, trusted the CA on Ubuntu and RHEL-family clients, and served the certificate with Nginx with optional mutual TLS. For larger fleets, consider an intermediate CA so the root key can stay offline, or an ACME-capable internal CA such as step-ca so certificates renew automatically. Also track every certificate's expiry date in your monitoring system.
