How to Create a Self-Signed SSL Certificate for Nginx

How to Create a Self-Signed SSL Certificate for Nginx

How to Create a Self-Signed SSL Certificate for Nginx

Every time I spin up a new development server or an internal tool that’s never going to touch the public internet, I don’t want to deal with a real certificate authority. That’s where self-signed certificates come in — they let me enable full HTTPS, test SSL-dependent features, and encrypt traffic between trusted parties, all without needing a domain validated by Let’s Encrypt or a paid CA. The trade-off is that browsers will always flag them as untrusted, which is exactly why I never use them for public production sites.

In this guide, I’ll walk through generating a self-signed certificate with OpenSSL, configuring Nginx to use it, testing it properly, and understanding exactly where this approach is appropriate and where it isn’t.

What a Self-Signed Certificate Actually Is

A normal SSL certificate is issued and signed by a trusted Certificate Authority (CA) like Let’s Encrypt, DigiCert, or others whose root certificates are baked into every browser and OS. A self-signed certificate is one where I act as my own CA — I generate a private key, use it to sign my own certificate, and there’s no third party vouching for its authenticity.

This means:

Requirements

Step 1: Create a Directory for the Certificate

I like to keep self-signed certs in a dedicated location, separate from where Let’s Encrypt stores its files, to avoid any confusion:

sudo mkdir -p /etc/nginx/ssl

Step 2: Generate the Private Key and Certificate

OpenSSL can generate both the private key and the self-signed certificate in a single command:

sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/nginx/ssl/selfsigned.key \
  -out /etc/nginx/ssl/selfsigned.crt

Breaking down what each flag does:

After running this, OpenSSL prompts for certificate details:

Country Name (2 letter code) []: PK
State or Province Name []: Islamabad
Locality Name []: Islamabad
Organization Name []: My Dev Environment
Organizational Unit Name []: IT
Common Name []: dev.example.com
Email Address []: admin@example.com

The Common Name (CN) field is the most important one — it needs to match the hostname you’ll actually be accessing (e.g., dev.example.com or localhost), or the browser’s security warning will include an additional hostname mismatch error on top of the usual “untrusted” warning.

Step 3: Generate a Stronger Certificate with Subject Alternative Names (Recommended)

Modern browsers increasingly ignore the legacy Common Name field and require a Subject Alternative Name (SAN) instead. If I skip this, Chrome may reject the certificate outright rather than just warning about it. I generate a config file to handle this properly:

sudo nano /etc/nginx/ssl/san.cnf
[req]
default_bits       = 2048
prompt             = no
default_md         = sha256
distinguished_name = dn
x509_extensions    = v3_req

[dn]
C  = PK
ST = Islamabad
L  = Islamabad
O  = My Dev Environment
CN = dev.example.com

[v3_req]
subjectAltName = @alt_names

[alt_names]
DNS.1 = dev.example.com
DNS.2 = localhost
IP.1  = 127.0.0.1

Then generate the certificate using this config:

sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/nginx/ssl/selfsigned.key \
  -out /etc/nginx/ssl/selfsigned.crt \
  -config /etc/nginx/ssl/san.cnf \
  -extensions v3_req

This produces a certificate that’s valid for multiple hostnames and IP addresses at once, which is genuinely how modern browsers expect certificates to be structured.

Step 4: Generate Diffie-Hellman Parameters (Optional but Recommended)

For stronger key exchange security, I generate a DH parameters file:

sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

This can take a minute or two to complete depending on the server’s CPU. It’s optional for a dev environment but good practice to include, and it’s a one-time cost.

Step 5: Configure Nginx to Use the Certificate

server {
    listen 443 ssl;
    http2 on;
    server_name dev.example.com;

    ssl_certificate /etc/nginx/ssl/selfsigned.crt;
    ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
    ssl_dhparam /etc/nginx/ssl/dhparam.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    root /var/www/dev.example.com;
    index index.html;

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

server {
    listen 80;
    server_name dev.example.com;
    return 301 https://$host$request_uri;
}

Step 6: Test and Reload

sudo nginx -t
sudo systemctl reload nginx

Testing the Self-Signed Certificate

curl -Ik https://dev.example.com/

Since curl doesn’t trust self-signed certificates by default, this returns a certificate error. That’s expected. To bypass verification for testing purposes only:

curl -Ik --insecure https://dev.example.com/

I should see HTTP/2 200 (or HTTP/1.1 200 OK) along with the certificate details if I add -v for verbose output.

In a browser, I’ll see a warning page (“Your connection isn’t private” in Chrome, or similar in Firefox). For development purposes, I can click through the advanced options to proceed, or better, add the certificate to my local trust store so the warning disappears entirely on my own machine (see below).

Trusting the Certificate Locally (Optional)

If I want my own development machine to stop showing warnings for this specific certificate, I import it into my OS or browser’s trust store.

On macOS:

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain /etc/nginx/ssl/selfsigned.crt

On Linux (Debian/Ubuntu):

sudo cp /etc/nginx/ssl/selfsigned.crt /usr/local/share/ca-certificates/selfsigned.crt
sudo update-ca-certificates

On Windows: Import the .crt file via certmgr.msc into “Trusted Root Certification Authorities.”

I only do this on machines I personally control, and only for certificates I generated myself — never for a certificate I didn’t create, since trusting an unknown self-signed cert defeats the entire point of certificate validation.

Complete Example Configuration

server {
    listen 80;
    server_name dev.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name dev.example.com;

    ssl_certificate /etc/nginx/ssl/selfsigned.crt;
    ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
    ssl_dhparam /etc/nginx/ssl/dhparam.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    root /var/www/dev.example.com;
    index index.html;

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

Troubleshooting Common Issues

nginx: [emerg] cannot load certificate on reload. Check the file paths in ssl_certificate and ssl_certificate_key are correct and that Nginx has read permission on both files:

sudo chmod 644 /etc/nginx/ssl/selfsigned.crt
sudo chmod 600 /etc/nginx/ssl/selfsigned.key

Browser shows “NET::ERR_CERT_COMMON_NAME_INVALID” even after generating with SAN. This means the hostname I’m visiting doesn’t match any entry in the certificate’s SAN list. I re-check the alt_names section in my config file and regenerate if needed.

Nginx prompts for a passphrase on every restart. This means I forgot the -nodes flag when generating the key, so OpenSSL encrypted the private key. I either regenerate without -nodes, or strip the passphrase from an existing key:

openssl rsa -in selfsigned.key -out selfsigned_nopass.key

Certificate expired. Self-signed certs I generate with -days 365 need manual renewal — there’s no automated renewal like Let’s Encrypt provides. I just re-run the generation command with a fresh validity period when it expires.

Security Considerations

Performance Tips

Real-World Use Cases

Best Practices

Wrapping Up

A self-signed certificate gets me full HTTPS functionality in minutes, without needing a real domain or a certificate authority — perfect for development and internal tooling. The two details that matter most are including a proper SAN block so modern browsers don’t outright reject it, and being disciplined about never letting one anywhere near real production traffic. Once it’s in place, everything else — HTTP/2, secure cookies, strict transport security testing — works exactly like it would with a certificate from a public CA.

Exit mobile version