Nginx on AlmaLinux 9
A practical reference for running nginx on AlmaLinux 9 — installation, basic management, virtual hosts, reverse proxying, SSL with Certbot, SELinux, and troubleshooting.
Installation
# Enable EPEL and Remi (optional, nginx is in base AppStream)
dnf install -y epel-release
# Install nginx
dnf install -y nginx
# Verify
nginx -v
nginx -V # detailed build info (modules, configure args)
Service Management
| Action | Command |
|---|---|
| Start | systemctl start nginx |
| Stop | systemctl stop nginx |
| Restart | systemctl restart nginx |
| Reload config (no downtime) | systemctl reload nginx or nginx -s reload |
| Enable at boot | systemctl enable nginx |
| Disable at boot | systemctl disable nginx |
| Check status | systemctl status nginx |
| Test config (before reload) | nginx -t |
Always run nginx -t before reloading to catch syntax errors:
nginx -t && systemctl reload nginx
Directory Layout
| Path | Purpose |
|---|---|
/etc/nginx/nginx.conf |
Main config file |
/etc/nginx/conf.d/ |
Supplemental config files (loaded at top level) |
/etc/nginx/default.d/ |
Config fragments included from default.conf |
/usr/share/nginx/html/ |
Default document root |
/var/log/nginx/access.log |
Access log |
/var/log/nginx/error.log |
Error log |
/etc/nginx/sites-available/ |
Virtual host configs (convention, not created by default) |
/etc/nginx/sites-enabled/ |
Symlinks to enabled sites (convention) |
Setting up sites-available/enabled (optional but recommended)
AlmaLinux nginx package does not create sites-available/sites-enabled directories by default. To use this pattern:
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
Then add an include line in the http block of /etc/nginx/nginx.conf:
include /etc/nginx/sites-enabled/*.conf;
Basic Configuration
Default /etc/nginx/nginx.conf structure
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
Simple static site
Create /etc/nginx/conf.d/mysite.conf:
server {
listen 80;
server_name mysite.example.com;
root /var/www/mysite;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Reverse Proxy
Proxy to a local application (e.g. FastAPI on port 8000)
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Proxy with WebSocket support
server {
listen 80;
server_name ws.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Upstream group (load balancing / multiple backends)
upstream backend {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Common proxy headers explained
| Header | Purpose |
|---|---|
Host $host |
Passes the original hostname so the backend knows which site was requested |
X-Real-IP $remote_addr |
Passes the client's real IP (not nginx's IP) |
X-Forwarded-For $proxy_add_x_forwarded_for |
Appends client IP to any existing X-Forwarded-For chain |
X-Forwarded-Proto $scheme |
Tells the backend whether the original request was HTTP or HTTPS |
Upgrade $http_upgrade / Connection "upgrade" |
Required for WebSocket connections |
SSL / TLS with Certbot
Install Certbot
dnf install -y certbot python3-certbot-nginx
Get and install a certificate (automatic nginx config update)
certbot --nginx -d app.example.com -d www.example.com
Certbot will: 1. Verify domain ownership via HTTP challenge (port 80) 2. Obtain the certificate from Let's Encrypt 3. Modify your nginx server block to add SSL directives and a redirect from HTTP to HTTPS
Get a certificate only (manual config)
certbot certonly --nginx -d app.example.com
Certificates are written to /etc/letsencrypt/live/app.example.com/.
Auto-renewal
Certbot installs a systemd timer by default:
systemctl list-timers | grep certbot
# or
certbot renew --dry-run # test renewal works
Certificates renew automatically when the timer fires (twice daily). No action needed.
SSL Configuration (manual)
If you prefer to configure SSL manually or need custom settings:
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
# Modern TLS config (Mozilla Intermediate)
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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
SELinux
AlmaLinux 9 ships with SELinux enforcing by default. This is the most common source of "Permission denied" errors in nginx error logs.
Check SELinux status
getenforce # Enforcing, Permissive, or Disabled
sestatus # detailed status
Common SELinux booleans for nginx
# Allow nginx to make network connections (proxying, fetching)
setsebool -P httpd_can_network_connect 1
# Allow nginx to connect to PostgreSQL / MySQL / LDAP
setsebool -P httpd_can_network_connect_db 1
# Allow nginx to send email
setsebool -P httpd_can_sendmail 1
# Allow nginx to use NFS / CIFS filesystems
setsebool -P httpd_use_nfs 1
setsebool -P httpd_use_cifs 1
SELinux context for custom web roots
If you serve files from a non-standard directory (not /usr/share/nginx/html), SELinux will block nginx from reading them:
semanage fcontext -a -t httpd_sys_content_t "/var/www/mysite(/.*)?"
restorecon -Rv /var/www/mysite
Troubleshooting SELinux denials
# Check for denials in real time
tail -f /var/log/audit/audit.log | grep nginx
# Or use ausearch
ausearch -m avc -ts recent | grep nginx
# Generate a custom policy module (alternative to setsebool)
grep nginx /var/log/audit/audit.log | audit2allow -M nginx_custom
semodule -i nginx_custom.pp
Common SELinux errors and fixes
| Error in nginx log | Cause | Fix |
|---|---|---|
connect() to 127.0.0.1:8000 failed (13: Permission denied) |
httpd_can_network_connect is off |
setsebool -P httpd_can_network_connect 1 |
open() "/var/www/mysite/index.html" failed (13: Permission denied) |
Wrong SELinux context on files | restorecon -Rv /var/www/mysite |
connect() to unix:/tmp/app.sock failed (13: Permission denied) |
nginx cannot access the socket | Check socket context, or move to /var/run/ |
Firewall
# Open HTTP and HTTPS
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
# Verify
firewall-cmd --list-all
Logs
# Tail access log
tail -f /var/log/nginx/access.log
# Tail error log
tail -f /var/log/nginx/error.log
# Combined format (default)
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
Log rotation
AlmaLinux configures logrotate for nginx automatically at /etc/logrotate.d/nginx. Logs rotate weekly with 52 weeks of history kept.
Performance Tuning
# /etc/nginx/nginx.conf
worker_processes auto; # one per CPU core
worker_connections 1024; # per worker process
keepalive_timeout 65; # drop idle connections
sendfile on; # efficient file serving
tcp_nopush on; # optimise packet headers
gzip on; # compress text responses
gzip_types text/css text/javascript application/json;
client_max_body_size 10M; # max upload size
Useful Commands
# Test configuration
nginx -t
# Quick syntax check (no daemon)
nginx -T
# Reload without dropping connections
nginx -s reload
# Graceful shutdown
nginx -s quit
# Immediate shutdown
nginx -s stop
# Reopen log files (after log rotation)
nginx -s reopen
Common Troubleshooting
| Symptom | Likely cause |
|---|---|
502 Bad Gateway |
Backend is not running, or SELinux blocking proxy |
Connection refused |
Backend is not listening on the expected IP/port |
Permission denied (proxy) |
SELinux boolean httpd_can_network_connect is off |
Permission denied (static files) |
SELinux context on web root is wrong |
404 for existing files |
Wrong root or try_files directive |
Certbot fails with .well-known 404 |
nginx location block blocking the ACME challenge path |
address already in use |
Another process on port 80/443, or nginx already running |