Host Static Sites with nginx
Install nginx on Linux, host multiple static sites with SSL, gzip/brotli compression, long-lived caching headers, and HTTP-to-HTTPS redirects.
Before you start
- ▸A Linux server with a public IP address and SSH access
- ▸DNS A records for each domain pointing to the server's IP
- ▸sudo or root privileges on the server
- ▸Ports 80 and 443 reachable from the internet (check cloud provider security groups)
nginx is a fast, low-memory web server that handles static files extremely well. Whether you are serving a personal blog, a marketing site, or a web app's compiled front-end, nginx is the right tool. This guide walks through installing nginx, configuring multiple virtual hosts, enabling compression, setting proper caching headers, redirecting HTTP to HTTPS, and terminating SSL—all on a modern Linux server.
Install nginx
Install from your distro's package manager. nginx in current LTS repositories is recent enough for everything covered here.
Debian / Ubuntu
sudo apt update && sudo apt install -y nginx
Fedora / RHEL 9 / Rocky Linux
sudo dnf install -y nginx
Arch Linux
sudo pacman -S nginx
Enable and start the service with systemd:
sudo systemctl enable --now nginx
Confirm it is running:
systemctl status nginx
Directory Structure for Multiple Sites
nginx uses a sites-available / sites-enabled pattern on Debian-family systems. Fedora and Arch use a single conf.d/ directory instead. Both patterns work the same way—each site gets its own config file.
Debian / Ubuntu layout
sudo mkdir -p /var/www/site-one/html /var/www/site-two/html
sudo chown -R $USER:$USER /var/www/site-one /var/www/site-two
Fedora / Arch / RHEL layout
sudo mkdir -p /usr/share/nginx/site-one/html /usr/share/nginx/site-two/html
sudo chown -R $USER:$USER /usr/share/nginx/site-one /usr/share/nginx/site-two
Drop a placeholder index file into each directory so you can test immediately:
echo '<h1>Site One</h1>' > /var/www/site-one/html/index.html
echo '<h1>Site Two</h1>' > /var/www/site-two/html/index.html
Write the Server Block Configs
Create a config file for each virtual host. The example below uses /var/www paths (Debian/Ubuntu). Adjust the root path if you are on Fedora or Arch. Replace site-one.example.com with your actual domain.
Site One — HTTP only (temporary, before SSL)
sudo nano /etc/nginx/sites-available/site-one
server {
listen 80;
listen [::]:80;
server_name site-one.example.com;
root /var/www/site-one/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Create a matching file for site two, then enable both by symlinking into sites-enabled (Debian/Ubuntu only—Fedora/Arch place files directly in /etc/nginx/conf.d/):
sudo ln -s /etc/nginx/sites-available/site-one /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/site-two /etc/nginx/sites-enabled/
Test the configuration before every reload—make this a habit:
sudo nginx -t && sudo systemctl reload nginx
Obtain SSL Certificates with Certbot
Let's Encrypt certificates are free and automatically renew. Certbot is the standard client.
Debian / Ubuntu
sudo apt install -y certbot python3-certbot-nginx
Fedora / RHEL / Rocky
sudo dnf install -y certbot python3-certbot-nginx
Arch
sudo pacman -S certbot certbot-nginx
Run Certbot for both domains. It edits your nginx configs automatically and sets up a systemd timer for renewal:
sudo certbot --nginx -d site-one.example.com -d site-two.example.com
Certbot will add listen 443 ssl blocks and certificate directives. Verify the renewal timer is active:
systemctl status certbot.timer
HTTP → HTTPS Redirect
After Certbot runs, your port-80 server block should be updated automatically. If not, or if you prefer to control it manually, replace the port-80 block with a clean redirect:
server {
listen 80;
listen [::]:80;
server_name site-one.example.com;
return 301 https://$host$request_uri;
}
A 301 is a permanent redirect. Browsers and search engines will cache it, so use 302 only while testing.
Gzip and Brotli Compression
Compression is configured globally in /etc/nginx/nginx.conf inside the http block, which makes it apply to all sites automatically.
Gzip (built-in, always available)
sudo nano /etc/nginx/nginx.conf
Add or uncomment inside the http { } block:
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
Brotli (requires the ngx_brotli module)
The stock nginx package does not include Brotli. On Debian/Ubuntu you can install it from the nginx PPA or compile it as a dynamic module. On Fedora 38+ it is available as nginx-mod-http-brotli. If you need Brotli on Debian/Ubuntu, the easiest path is the official nginx apt repo:
# Fedora / RHEL
sudo dnf install -y nginx-mod-http-brotli
Then load the modules and configure them in nginx.conf:
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;
brotli on;
brotli_comp_level 6;
brotli_types
text/plain
text/css
application/javascript
application/json
image/svg+xml
font/woff2;
Caching Headers
Browsers cache assets based on response headers. Set aggressive caching for fingerprinted assets (anything with a hash in the filename) and short or no caching for HTML, which changes frequently.
Add these location blocks inside your HTTPS server block:
# HTML — always revalidate
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
# Fingerprinted assets — cache for 1 year
location ~* \.(css|js|woff2|woff|ttf|ico|webp|png|jpg|jpeg|gif|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
The immutable directive tells the browser not to revalidate the asset even on a hard reload, which is safe only when file names change on each build (as all modern front-end build tools do).
Security Headers (Bonus)
While you have the config open, add these inside the HTTPS server block. They cost nothing and meaningfully improve security posture:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Open Firewall Ports
Your firewall must allow ports 80 and 443. Commands vary by firewall tool.
ufw (Debian / Ubuntu)
sudo ufw allow 'Nginx Full'
sudo ufw reload
firewalld (Fedora / RHEL / Rocky)
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
Verify Everything Works
Run a final config test, reload, and then check each site and certificate:
sudo nginx -t && sudo systemctl reload nginx
# Check HTTP redirect
curl -I http://site-one.example.com
# Check HTTPS response and compression
curl -sI --compressed https://site-one.example.com | grep -E 'content-encoding|cache-control|strict-transport'
You should see content-encoding: gzip (or br for Brotli), appropriate cache-control values, and the HSTS header. Use securityheaders.com and SSL Labs for a full audit.
Troubleshooting
- 502 / blank page after config change: Run
sudo nginx -tfirst. Syntax errors prevent reload from applying changes but nginx keeps serving the last valid config. - Permission denied on document root: SELinux on RHEL-family systems may block nginx from reading
/var/www. Runsudo restorecon -Rv /var/wwwor set the correct context withchcon -Rt httpd_sys_content_t /var/www/site-one. - Certbot fails with port 80 blocked: Ensure your firewall rule is in place and no other process is listening on port 80 (
ss -tlnp | grep :80). - Brotli module load fails: Confirm the module file exists at the path specified in
load_modulewithls /usr/lib64/nginx/modules/and adjust the path accordingly. - Logs: Access and error logs live at
/var/log/nginx/access.logand/var/log/nginx/error.log. Tail them withsudo journalctl -u nginx -ffor real-time output.
Frequently asked questions
- Can I host multiple domains with different SSL certificates on one server?
- Yes. Each server block has its own ssl_certificate and ssl_certificate_key directives. Certbot manages separate certificates per domain automatically, and nginx uses SNI to route TLS connections correctly.
- Why does my CSS or JS still not compress even with gzip on?
- Check that the MIME type is listed in gzip_types. Also confirm the response is not already compressed (nginx skips gzip if Content-Encoding is already set). Use `curl -sI --compressed` to inspect the actual response headers.
- Is the immutable Cache-Control directive safe to use?
- Only when asset file names change on each build—which tools like Vite, Webpack, and Parcel do by default by appending a content hash. If you serve the same filename with updated content, remove `immutable` and rely on max-age alone.
- Does nginx serve Brotli automatically or only if the browser requests it?
- nginx negotiates compression based on the Accept-Encoding request header sent by the browser. Brotli is only sent to clients that advertise `br` in that header, so there is no risk of sending incompatible responses.
- How do I reload nginx without dropping active connections?
- Use `sudo systemctl reload nginx` or `sudo nginx -s reload`. Both send SIGHUP, which causes nginx to re-read its config and gracefully hand off existing connections to new worker processes without any downtime.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.