The Error
Your Nginx starts (or reloads) and you see this in the logs:
SSL: error:0200100D:system library:fopen:Permission denied (SSL: error:20074002:BIO routines:file_ctrl:system lib)
Translation: Nginx tried to open a certificate or private key file, and the OS said no. The process doesn't have read permission โ end of story.
This usually surfaces right after nginx -t or systemctl reload nginx. Check /var/log/nginx/error.log and it'll point you to the exact file that failed.
Root Cause
Nginx splits work between a master process and worker processes. The master runs as root โ that part starts fine. Workers, though, run as a low-privilege user: www-data on Debian/Ubuntu, nginx on CentOS/RHEL. Those workers are what actually handle SSL connections, and they inherit no special file access.
A few things trigger this:
- The cert or key file is owned by
root:rootwith600permissions. Only root reads it; the Nginx worker user can't touch it. - The parent directory has
700permissions, so the worker can't even traverse into it โ the files might as well not exist. - You renewed via
certbotand/etc/letsencrypt/live/and/etc/letsencrypt/archive/are locked to700by default. They are, always. - SELinux or AppArmor is blocking access even though
ls -lalooks perfectly fine.
Step 1 โ Find the Exact File Nginx Can't Open
Run the config test first:
sudo nginx -t 2>&1
Or grep the error log if the service is already running:
sudo tail -50 /var/log/nginx/error.log | grep -i 'permission\|fopen\|SSL'
The output will name the file. Write it down โ you'll need it shortly.
Step 2 โ Check Current Permissions
Inspect the cert directory and its contents:
ls -la /etc/nginx/ssl/
# or for Let's Encrypt:
ls -la /etc/letsencrypt/live/yourdomain.com/
ls -la /etc/letsencrypt/archive/yourdomain.com/
Then confirm which user Nginx actually runs as:
grep -E '^user' /etc/nginx/nginx.conf
# Typically: user www-data; (Debian/Ubuntu)
# or: user nginx; (CentOS/RHEL)
Fix A โ Certificate Files Outside Let's Encrypt (Most Common)
Certs stored in /etc/nginx/ssl/? Set group ownership to the Nginx user and tighten permissions:
# Swap www-data for nginx if you're on CentOS/RHEL
sudo chown root:www-data /etc/nginx/ssl/
sudo chmod 750 /etc/nginx/ssl/
sudo chown root:www-data /etc/nginx/ssl/yourdomain.crt
sudo chown root:www-data /etc/nginx/ssl/yourdomain.key
# 640 = root can read/write, group (Nginx worker) can read, world gets nothing
sudo chmod 640 /etc/nginx/ssl/yourdomain.crt
sudo chmod 640 /etc/nginx/ssl/yourdomain.key
The Nginx worker gets read access through group membership. The private key stays off-limits to everyone else.
If you can never remember octal values off the top of your head, the Unix Permissions Calculator on ToolCraft lets you build the right number visually โ click the checkboxes, get the octal.
Fix B โ Let's Encrypt Certificates (Certbot)
Certbot locks /etc/letsencrypt/ to 700 on purpose. You have two clean ways around it.
Option 1: Add the Nginx user to the ssl-cert group (Debian/Ubuntu):
sudo usermod -aG ssl-cert www-data
sudo chgrp ssl-cert /etc/letsencrypt/live/
sudo chgrp ssl-cert /etc/letsencrypt/archive/
sudo chmod g+rx /etc/letsencrypt/live/
sudo chmod g+rx /etc/letsencrypt/archive/
sudo chgrp -R ssl-cert /etc/letsencrypt/archive/yourdomain.com/
sudo chmod -R g+r /etc/letsencrypt/archive/yourdomain.com/
Option 2: Copy certs to a directory Nginx controls (simpler, works everywhere):
sudo mkdir -p /etc/nginx/ssl
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /etc/nginx/ssl/
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /etc/nginx/ssl/
sudo chown root:www-data /etc/nginx/ssl/*.pem
sudo chmod 640 /etc/nginx/ssl/*.pem
Copying certs introduces a sync problem โ fix it with a deploy hook that runs on every renewal:
# /etc/letsencrypt/renewal-hooks/deploy/copy-to-nginx.sh
#!/bin/bash
cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /etc/nginx/ssl/
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /etc/nginx/ssl/
chown root:www-data /etc/nginx/ssl/*.pem
chmod 640 /etc/nginx/ssl/*.pem
systemctl reload nginx
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/copy-to-nginx.sh
Fix C โ SELinux Is Blocking Access (CentOS/RHEL)
Permissions look right, but the error keeps coming back on CentOS/RHEL? SELinux is almost certainly the culprit. Pull the audit log:
sudo ausearch -m avc -ts recent | grep nginx
AVC denials there? Relabel the cert files with the correct SELinux context:
sudo chcon -t cert_t /etc/nginx/ssl/yourdomain.key
sudo chcon -t cert_t /etc/nginx/ssl/yourdomain.crt
# If the files are already in a standard location, restorecon is cleaner:
sudo restorecon -Rv /etc/nginx/ssl/
Fix D โ AppArmor Profile (Ubuntu)
Ubuntu's AppArmor can silently block file access even when permissions are correct. Check whether Nginx has an active profile:
sudo aa-status | grep nginx
sudo dmesg | grep -i apparmor | grep nginx
If you see a profile loaded, set it to complain mode while you debug. That lets Nginx run while logging what it would have blocked:
sudo aa-complain /etc/apparmor.d/usr.sbin.nginx
# Once it works, add the cert path to the profile properly, then re-enforce
Verify the Fix
Run through this checklist after any of the fixes above:
# Config test โ you want: syntax is ok + test is successful
sudo nginx -t
# Reload
sudo systemctl reload nginx
# Any new SSL errors?
sudo tail -20 /var/log/nginx/error.log
# Confirm TLS handshake from the outside
curl -vI https://yourdomain.com 2>&1 | grep -E 'SSL|TLS|certificate|subject'
A clean result shows the TLS handshake completing and the certificate subject in the output. No errors in the log means you're done.
Prevention
- Bake permissions into your provisioning scripts. Whether you use Ansible, Terraform, or a plain bash setup script, set cert ownership and chmod at deploy time. Permissions that drift after the fact are the whole reason you're reading this.
- Always wire up a certbot deploy hook. Every renewal should re-apply permissions. The hook in Fix B handles this with five lines of shell.
- Never
chmod 777a private key. Private keys belong at600(root only) or640(root + web server group). Anything broader is a security problem, not a fix. - Trace the full path before going live. Run
namei -om /path/to/your.keyโ it walks every component of the path and shows you exactly where access breaks down. Catches directory permission issues thatls -laon the file alone misses.

