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 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