SSL Certificate Generation and Installation for Apache and Nginx
At a glance#
- Purpose: Generate a CSR, install a commercial certificate, and set up automatic Let's Encrypt renewal on Apache and Nginx.
- Applies to: Apache 2.4 and Nginx 1.18+ on Ubuntu and RHEL-family systems.
- Risk: Medium — a bad certificate or config error takes HTTPS down until corrected.
- Time: 30 minutes for Let's Encrypt; 1–2 days for a commercial certificate including issuance.
Overview#
There are two routes, and choosing correctly saves a lot of work:
| Let's Encrypt | Commercial CA | |
|---|---|---|
| Cost | Free | Paid |
| Validity | 90 days | 1 year |
| Renewal | Automatic | Manual |
| Validation | Domain only | Domain, organisation or extended |
| Requires public reachability | Yes (HTTP-01) | No |
| Wildcards | Yes (DNS-01 only) | Yes |
Use Let's Encrypt for anything publicly reachable. Use a commercial certificate where the organisation must be named in the certificate, or where the host is not reachable from the internet.
Part 1 — Commercial certificate: generating the CSR#
1. Generate a private key and CSR#
sudo mkdir -p /etc/ssl/example.com
cd /etc/ssl/example.com
sudo openssl req -new -newkey rsa:2048 -nodes \
-keyout example.com.key \
-out example.com.csrAnswer the prompts:
| Prompt | Value |
|---|---|
| Country Name | NP |
| State or Province | Bagmati |
| Locality | Kathmandu |
| Organization Name | Exact registered company name |
| Organizational Unit | IT |
| Common Name | The exact FQDN, e.g. www.example.com |
| Email Address | Leave blank |
| Challenge password | Leave blank |
Warning: Leave the challenge password empty. A passphrase on the key means the web server cannot start unattended — it will prompt for the passphrase on every boot.
Note: Common Name must match exactly how users reach the site.example.comandwww.example.comare different names. Most CAs now include both automatically, but confirm before submitting.
2. Protect the private key#
sudo chmod 600 /etc/ssl/example.com/example.com.key
sudo chown root:root /etc/ssl/example.com/example.com.keyWarning: The private key never leaves this server and is never emailed. If it is exposed, the certificate must be revoked and reissued.
3. Verify the CSR before submitting#
openssl req -in example.com.csr -noout -text | head -20Confirm the Common Name and organisation are correct. A typo here means paying for a reissue.
4. Submit to the CA#
Paste the contents of example.com.csr into the CA's order form:
cat /etc/ssl/example.com/example.com.csrComplete domain validation — usually an email to admin@example.com or a DNS TXT record.
5. Install the issued certificate#
The CA returns your certificate and an intermediate bundle. Place both:
sudo cp example_com.crt /etc/ssl/example.com/example.com.crt
sudo cp example_com.ca-bundle /etc/ssl/example.com/example.com.ca-bundle
sudo chmod 644 /etc/ssl/example.com/*.crt /etc/ssl/example.com/*.ca-bundleConfirm the certificate matches the key before restarting anything:
openssl x509 -noout -modulus -in example.com.crt | openssl md5
openssl rsa -noout -modulus -in example.com.key | openssl md5Both hashes must be identical.
Part 2 — Installing on Nginx#
Nginx needs the certificate and intermediates in one file, certificate first:
cd /etc/ssl/example.com
sudo bash -c 'cat example.com.crt example.com.ca-bundle > example.com.fullchain.crt'Edit the site configuration:
sudo nano /etc/nginx/sites-available/example.comserver {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/ssl/example.com/example.com.fullchain.crt;
ssl_certificate_key /etc/ssl/example.com/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=31536000" always;
root /var/www/example.com;
index index.html;
}Test and reload:
sudo nginx -t
sudo systemctl reload nginxWarning: Always run nginx -t first. A reload with a broken config leaves the previous configuration running, but a restart with a broken config leaves the site down.
Part 3 — Installing on Apache#
Apache takes the certificate and chain as separate directives:
sudo nano /etc/apache2/sites-available/example.com-ssl.conf # Debian/Ubuntu
# or /etc/httpd/conf.d/example.com-ssl.conf # RHEL<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
Redirect permanent / https://example.com/
</VirtualHost>
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /var/www/example.com
SSLEngine on
SSLCertificateFile /etc/ssl/example.com/example.com.crt
SSLCertificateKeyFile /etc/ssl/example.com/example.com.key
SSLCertificateChainFile /etc/ssl/example.com/example.com.ca-bundle
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
Header always set Strict-Transport-Security "max-age=31536000"
</VirtualHost>Enable the required modules and site:
sudo a2enmod ssl headers
sudo a2ensite example.com-ssl
sudo apache2ctl configtest
sudo systemctl reload apache2On RHEL:
sudo httpd -t
sudo systemctl reload httpdPart 4 — Let's Encrypt with certbot#
1. Install certbot#
# Ubuntu / Debian
sudo apt update
sudo apt install certbot python3-certbot-nginx python3-certbot-apache -y
# RHEL / Rocky / AlmaLinux
sudo dnf install epel-release -y
sudo dnf install certbot python3-certbot-nginx python3-certbot-apache -y2. Obtain and install the certificate#
Certbot edits the web server config for you:
# Nginx
sudo certbot --nginx -d example.com -d www.example.com
# Apache
sudo certbot --apache -d example.com -d www.example.comTo obtain a certificate without touching the config:
sudo certbot certonly --webroot -w /var/www/example.com -d example.com -d www.example.comCertificates are written to /etc/letsencrypt/live/example.com/.
Note: HTTP-01 validation requires port 80 open to the internet. For internal hosts or wildcards, use DNS-01 instead: ``bash sudo certbot certonly --manual --preferred-challenges dns -d "*.example.com" ``
3. Confirm automatic renewal#
The packages install a systemd timer. Verify it:
systemctl list-timers | grep certbot
sudo certbot renew --dry-runA successful dry run is the only proof renewal will work. Certificates last 90 days — an unverified renewal is a scheduled outage.
Add a reload hook so the web server picks up the new certificate:
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-web.sh#!/bin/bash
systemctl reload nginx 2>/dev/null || systemctl reload apache2 2>/dev/null || systemctl reload httpdsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-web.shVerification#
Check the served certificate and chain:
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | \
openssl x509 -noout -subject -issuer -datesConfirm the chain validates:
echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 | grep "Verify return code"Verify return code: 0 (ok) is the target. Anything else usually means a missing intermediate.
Confirm the HTTP redirect and protocol support:
curl -sI http://example.com | head -1 # expect 301
curl -sI --tlsv1.2 https://example.com | head -1Set a calendar reminder 30 days before expiry for commercial certificates.
Troubleshooting#
| Symptom | Cause and fix |
|---|---|
SSL_ERROR_BAD_CERT_DOMAIN | Certificate CN/SAN does not cover the hostname used. Reissue with correct names. |
| Browser warns of incomplete chain | Intermediates missing. Nginx needs them concatenated; Apache needs SSLCertificateChainFile. |
key values mismatch on restart | Certificate and key are from different CSRs. Compare moduli. |
Nginx PEM_read_bio_X509 error | Wrong file order in the fullchain, or a stray blank line. Certificate must come first. |
| Apache prompts for a passphrase at boot | Key is encrypted. Remove it: openssl rsa -in enc.key -out plain.key |
| certbot: "Could not bind to port 80" | Web server already using it. Use --nginx/--apache plugins or --webroot rather than --standalone. |
| certbot: "Timeout during connect" | Port 80 not reachable from the internet. Check the firewall, or switch to DNS-01. |
| Renewal succeeded but old certificate still served | Web server was not reloaded. Add the deploy hook above. |
| Rate limit from Let's Encrypt | Five failures per hour per account. Use --dry-run while testing. |