Let's Encrypt covers most websites, but sometimes you need to install a certificate yourself: an OV or EV certificate bought from a commercial certificate authority (CA), a certificate issued by your company's internal CA, or a self-signed certificate for a lab or internal service. The process is always the same: generate a private key, create a certificate signing request (CSR), receive the signed certificate, and install it together with its intermediate chain. In this tutorial you will do all of that with OpenSSL on Ubuntu 24.04 and configure Nginx, with the Apache equivalent, to serve the certificate.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Nginx (or Apache) installed and serving your site over HTTP.
  • A domain name with DNS records pointing to the server. This guide uses your_domain as a placeholder.
  • Access to your certificate provider's control panel to submit the CSR and complete domain validation, if you're buying a certificate.
  • Port 443 open in the firewall: sudo ufw allow 'Nginx Full' or sudo ufw allow 'Apache Full'.

OpenSSL is installed by default on Ubuntu. Check the version:

openssl version
OpenSSL 3.0.13 30 Jan 2024 (Library: OpenSSL 3.0.13 30 Jan 2024)

Step 1 - Generating the private key and CSR

The private key never leaves your server; the CSR contains the public key and the names you want in the certificate, and it is what you send to the CA. Create a directory for the certificate files and move into it. Certificates are public, so the directory can be world-readable; only the key file will be restricted:

sudo mkdir -p /etc/ssl/your_domain
cd /etc/ssl/your_domain

Generate a 2048-bit RSA key and the CSR in a single command. Browsers ignore the Common Name and validate only the Subject Alternative Name (SAN) extension, so list every hostname in subjectAltName:

sudo openssl req -new -newkey rsa:2048 -nodes \
  -keyout your_domain.key \
  -out your_domain.csr \
  -subj "/C=ES/O=Your Company/CN=your_domain" \
  -addext "subjectAltName=DNS:your_domain,DNS:www.your_domain"
  • -nodes leaves the key unencrypted so Nginx can start without asking for a passphrase. The file permissions protect it instead.
  • -subj fills in the subject non-interactively. For DV certificates only the CN matters; for OV/EV certificates use your legal organization name exactly as the CA will validate it.
  • For a wildcard certificate, use CN=*.your_domain and subjectAltName=DNS:*.your_domain,DNS:your_domain.

If your CA accepts ECDSA certificates, you can use a smaller and faster key with -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 instead of -newkey rsa:2048.

Lock down the private key:

sudo chmod 600 your_domain.key

Check the CSR before you submit it:

sudo openssl req -in your_domain.csr -noout -subject -verify
sudo openssl req -in your_domain.csr -noout -text | grep -A1 "Subject Alternative Name"
Certificate request self-signature verify OK
subject=C = ES, O = Your Company, CN = your_domain
            X509v3 Subject Alternative Name:
                DNS:your_domain, DNS:www.your_domain

Print the CSR and paste the whole block, including the BEGIN and END lines, into your CA's order form:

sudo cat your_domain.csr

Complete the validation the CA asks for (an email to an admin address, a DNS TXT record or a file under /.well-known/pki-validation/). The CA then sends you the signed certificate.

Step 2 - Building the certificate chain

The CA normally delivers your server certificate plus one or more intermediate certificates (often called a "CA bundle"). Browsers need the intermediates to build a path to a trusted root, so the server must send them. Copy the files to the server, for example with scp, and place them in /etc/ssl/your_domain/:

  • your_domain.crt: your server certificate.
  • ca_bundle.crt: the intermediate certificate or certificates.

Confirm what each file contains:

sudo openssl x509 -in your_domain.crt -noout -subject -issuer -enddate
sudo openssl x509 -in ca_bundle.crt -noout -subject -issuer
subject=CN = your_domain
issuer=C = US, O = Example CA, CN = Example CA RSA Intermediate
notAfter=Apr 12 23:59:59 2027 GMT
subject=C = US, O = Example CA, CN = Example CA RSA Intermediate
issuer=C = US, O = Example CA, CN = Example CA Root

The issuer of your certificate must match the subject of the first intermediate. Verify that the chain validates against Ubuntu's trusted root store:

sudo openssl verify -untrusted ca_bundle.crt your_domain.crt
your_domain.crt: OK

Nginx expects the server certificate followed by the intermediates in one file. Order matters: your certificate first, then the intermediates.

sudo sh -c 'cat your_domain.crt ca_bundle.crt > fullchain.crt'

If the CA's files don't end with a newline, the END CERTIFICATE and BEGIN CERTIFICATE lines may end up on the same line. Check with sudo grep -c "BEGIN CERTIFICATE" fullchain.crt: the number must equal the certificates you concatenated.

Step 3 - Checking that the key matches the certificate

A certificate only works with the private key its CSR was created from. Compare the public key in both files; the two hashes must be identical:

sudo openssl x509 -in your_domain.crt -noout -pubkey | openssl sha256
sudo openssl pkey -in your_domain.key -pubout | openssl sha256
SHA2-256(stdin)= 3c1f9e0b7a...
SHA2-256(stdin)= 3c1f9e0b7a...

This comparison works for both RSA and ECDSA keys. If the hashes differ, you are using a key from a different CSR, and Nginx will refuse to start with a key values mismatch error.

Step 4 - Configuring Nginx

Open your site's server block:

sudo nano /etc/nginx/sites-available/your_domain

Replace its contents with an HTTP server that redirects to HTTPS and an HTTPS server that uses the new files. Adjust root to your site's document root:

server {
    listen 80;
    listen [::]:80;
    server_name your_domain www.your_domain;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your_domain www.your_domain;

    ssl_certificate     /etc/ssl/your_domain/fullchain.crt;
    ssl_certificate_key /etc/ssl/your_domain/your_domain.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    root /var/www/your_domain;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

TLS 1.2 and 1.3 with OpenSSL's default cipher list is a secure baseline for modern clients. The listen ... http2 syntax is the one supported by Nginx 1.24, the version in Ubuntu 24.04.

Enable the site if it isn't already, test the configuration and reload:

sudo ln -s /etc/nginx/sites-available/your_domain /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

If the symlink already exists, ln reports File exists, which is fine.

Configuring Apache instead

On Apache, enable the SSL module:

sudo a2enmod ssl

Create a virtual host for HTTPS:

sudo nano /etc/apache2/sites-available/your_domain-ssl.conf
<VirtualHost *:443>
    ServerName your_domain
    ServerAlias www.your_domain
    DocumentRoot /var/www/your_domain

    SSLEngine on
    SSLCertificateFile    /etc/ssl/your_domain/fullchain.crt
    SSLCertificateKeyFile /etc/ssl/your_domain/your_domain.key
    SSLProtocol -all +TLSv1.2 +TLSv1.3
</VirtualHost>

Since Apache 2.4.8, SSLCertificateFile can contain the certificate followed by its intermediates, so the same fullchain.crt works and the old SSLCertificateChainFile directive is no longer needed. In your port 80 virtual host, add Redirect permanent / https://your_domain/ to send visitors to HTTPS. Then enable the site and reload:

sudo a2ensite your_domain-ssl
sudo apache2ctl configtest
sudo systemctl reload apache2

Step 5 - Verifying the installation

Check what the server actually sends, including the chain. The -servername option sets SNI, which is required when several sites share one IP address:

echo | openssl s_client -connect your_domain:443 -servername your_domain 2>/dev/null | grep -E "^ *[0-9] s:|Verify return code"
 0 s:CN = your_domain
 1 s:C = US, O = Example CA, CN = Example CA RSA Intermediate
Verify return code: 0 (ok)

Depth 0 is your certificate and depth 1 the intermediate. Verify return code: 0 (ok) means a client with the standard root store trusts the chain. A code of 20 or 21 (unable to get local issuer certificate) means the intermediates are missing from fullchain.crt.

Confirm the dates and names:

echo | openssl s_client -connect your_domain:443 -servername your_domain 2>/dev/null | openssl x509 -noout -subject -enddate -ext subjectAltName

For a complete external check of the chain, protocols and ciphers, run your domain through the SSL Labs Server Test.

Creating a self-signed certificate for internal use

For a service that only you or your team access (a lab, an admin panel behind a VPN), a self-signed certificate encrypts traffic without buying anything. Browsers will show a warning because no trusted CA signed it, so never use one on a public website. Generate the key and certificate in one command:

sudo openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout /etc/ssl/your_domain/selfsigned.key \
  -out /etc/ssl/your_domain/selfsigned.crt \
  -subj "/CN=internal.your_domain" \
  -addext "subjectAltName=DNS:internal.your_domain,IP:your_server_ip"
sudo chmod 600 /etc/ssl/your_domain/selfsigned.key

Including the IP address in the SAN lets clients connect by IP without a name mismatch. Point ssl_certificate and ssl_certificate_key to these two files; there's no chain to build. To avoid browser warnings, import selfsigned.crt into the trust store of the machines that access the service. Copy the file to the client and, on an Ubuntu client, run:

sudo cp selfsigned.crt /usr/local/share/ca-certificates/internal-your_domain.crt
sudo update-ca-certificates

Tracking expiry

Nothing renews a manually installed certificate for you. Check how many days remain:

sudo openssl x509 -in /etc/ssl/your_domain/your_domain.crt -noout -enddate

-checkend returns a non-zero exit code if the certificate expires within the given number of seconds, which makes it easy to use from a monitoring system:

sudo openssl x509 -in /etc/ssl/your_domain/your_domain.crt -noout -checkend 2592000
Certificate will not expire

2592000 seconds is 30 days. When a renewal is due, generate a new key and CSR (Step 1), install the new files, run Steps 3 and 4 again and reload the web server. Keep the old files until the new certificate is verified.

Troubleshooting

SSL_CTX_use_PrivateKey ... key values mismatch when testing Nginx. The key doesn't belong to the certificate, or the files in fullchain.crt are in the wrong order. Run the hash comparison from Step 3 and make sure your server certificate is the first block in fullchain.crt.

Works in the browser, fails in curl or mobile apps. Desktop browsers sometimes fetch missing intermediates on their own; other clients don't. Check the chain with the s_client command in Step 5 and add the intermediates.

Permission denied reading the key. Nginx reads keys as root before dropping privileges, so root:root with mode 600 is correct. Check that the path in the configuration is right with sudo ls -l /etc/ssl/your_domain/.

PEM_read_bio_X509_AUX ... no start line. The file isn't in PEM format. If the CA sent a DER file (.cer or .der), convert it with openssl x509 -inform der -in your_domain.cer -out your_domain.crt.

Conclusion

You generated a private key and CSR with OpenSSL, built the certificate chain, verified that the key matches, and configured Nginx or Apache to serve the certificate over TLS 1.2 and 1.3. Keep a note of the expiry date, because manual certificates are renewed by hand. As next steps, add expiry checks to your monitoring, add a Strict-Transport-Security header once the whole site works over HTTPS, and consider Let's Encrypt with Certbot for sites that don't require a commercial certificate.