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)

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 static file offload (bypass Python for /static/)

For better performance, serve static assets (images, MP3s, JS, CSS) directly from nginx instead of proxying them to the Python backend. Add a location /static/ block before the catch-all proxy:

server {
    listen 443 ssl http2;
    server_name app.example.com;

    # Static files — served directly by nginx (zero-copy sendfile)
    location /static/ {
        alias /home/user/your-project/static/;
        expires 7d;
        add_header Cache-Control "public, immutable";
    }

    # Everything else — proxy to Python backend
    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;
    }
}

Important: When your static files live under a home directory (e.g. /home/user/project/static/), the nginx user must be able to traverse each directory in the path. Home directories default to 700 permissions, which blocks nginx:

chmod +x /home/user
chmod +x /home/user/your-project

The +x (execute) bit on directories allows nginx to enter them without granting read access to other files in your home directory.

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. Apply the httpd_sys_content_t context:

semanage fcontext -a -t httpd_sys_content_t "/var/www/mysite(/.*)?"
restorecon -Rv /var/www/mysite

If your web root is in a user's home directory, the same rules apply. SELinux contexts must match regardless of location:

semanage fcontext -a -t httpd_sys_content_t "/home/user/your-project/static(/.*)?"
restorecon -Rv /home/user/your-project/static

After applying, verify the context took:

ls -Z /home/user/your-project/static/
# Each file should show: system_u:object_r:httpd_sys_content_t:s0

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 | semanage fcontext ... && restorecon -Rv /var/www/mysite | | open() "/home/user/project/static/..." failed (13: Permission denied) | Missing +x on home directory or wrong SELinux context | Check Unix perms first: namei -l /home/user/project/static/file.js. Fix: chmod +x /home/user /home/user/project. Then check SELinux: ls -Z, apply httpd_sys_content_t if needed | | 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) Wrong SELinux context on files
Permission denied (static files, home dir) nginx user can't traverse /home/user/ path
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