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:
- Encryption works exactly the same as with a CA-issued certificate — the traffic is genuinely encrypted.
- Trust doesn’t work the same way — browsers have no way to verify I am who I claim to be, so they show a warning (“Your connection is not private” or similar) unless the certificate is manually trusted on that specific machine.
Requirements
- Nginx installed
- OpenSSL installed (present by default on virtually every Linux distribution)
- Root or sudo access
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:
-x509tells OpenSSL to output a self-signed certificate instead of a certificate signing request (CSR)-nodesskips encrypting the private key with a passphrase (important — if I add a passphrase, Nginx will refuse to start without me manually typing it in every time it restarts)-days 365sets the certificate’s validity period-newkey rsa:2048generates a new 2048-bit RSA key alongside the certificate-keyoutand-outspecify where the key and certificate files are written
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
- Self-signed certificates should never be used for public-facing production sites. Browsers will scare away real users, and there’s no revocation mechanism if the private key is ever compromised.
- Keep the private key file (
selfsigned.key) permissions locked down to600and owned by root — anyone who can read it can impersonate the server. - Self-signed certs are appropriate for: local development, internal tools on a private network, staging environments behind a VPN, or service-to-service encryption within a trusted infrastructure where both ends are configured to trust the specific certificate.
- If you need real trust without a public domain, consider running your own internal CA and distributing its root certificate to your organization’s devices, which scales better than trusting individual self-signed certs one by one.
Performance Tips
- Self-signed certificates have identical performance characteristics to CA-issued ones — the actual TLS handshake and encryption overhead doesn’t change based on who signed the certificate.
- Use 2048-bit RSA keys for a good balance of security and handshake speed in development; 4096-bit adds CPU overhead with little practical benefit for non-production use.
- If you’re testing performance-sensitive features, remember the DH parameter generation step is a one-time setup cost, not a per-request cost.
Real-World Use Cases
- Local development environments where HTTPS-dependent features (secure cookies, service workers, WebRTC) need to be tested.
- Internal admin tools or dashboards accessed only over a VPN or private network.
- Staging environments where the team already accepts browser warnings as part of the workflow.
- Encrypting traffic between two internal services where both ends are explicitly configured to trust a shared self-signed cert.
- Quickly testing SSL/TLS configuration changes (protocols, ciphers, HTTP/2) before applying them with a real certificate.
Best Practices
- Always include a proper SAN section — relying on just the legacy Common Name field will cause certificate errors in modern browsers.
- Use
-nodesfor server certificates so Nginx can start without manual passphrase entry. - Lock down private key file permissions immediately after generation.
- Never expose a self-signed certificate to public internet traffic expecting real users to trust it.
- Track expiration dates manually, since there’s no automatic renewal — I set a calendar reminder for anything longer-lived than a few months.
- Document clearly in your team’s runbook which environments intentionally use self-signed certs, so nobody mistakes the warning for a production incident.
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.
