An Nginx virtual host is a server block that ties a domain name, a site root, and the PHP handler together into one config. On a VDS with several sites, these blocks are exactly what decides which request goes where.
Nginx Virtual Host Configuration
Create a separate config file for the site — do not edit default directly once there is more than one site:
nano /etc/nginx/sites-available/yourdomain.com
File contents — a minimal working block for a PHP site:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/yourdomain.com;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}
Enable the config with a symlink into sites-enabled — Nginx reads configs from that folder specifically, while sites-available is just storage:
ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Several Sites on One IP
Nginx picks the right block based on the Host header from the request, matching it against the list in server_name. If no block matches, the block with default_server on its listen directive fires — without it, the first config read by file order becomes the implicit default, which can occasionally produce an unexpected result when a new site is added.
server {
listen 80 default_server;
server_name _;
return 444;
}
A stub block like this with return 444; drops the connection for any request with a domain that is not explicitly configured — a useful defense against bots that scan IP addresses directly, bypassing DNS.
Gzip Compression
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_min_length skips compressing very small files, where the gain is smaller than the overhead of packing them, while gzip_comp_level in the 5–6 range is a reasonable balance between saving bandwidth and CPU load; level 9 barely improves the size but is noticeably more expensive on the processor.
Browser Caching for Static Assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
Common Errors Explained
The error nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use) means port 80 is already taken by another process — most often Apache left running, or a second Nginx instance. Check the ports in use with ss -tulpn | grep :80 and stop the extra process.
502 Bad Gateway — Nginx did not get a response from the backend named in fastcgi_pass or proxy_pass. The exact cause is always in /var/log/nginx/error.log: a line like connect() to unix:/run/php/php8.2-fpm.sock failed (13: Permission denied) points to socket access permissions, while (111: Connection refused) means PHP-FPM simply is not running.
404 on every page except the home page in projects with "pretty" URLs usually means the try_files directive is not set up to fall through to index.php — Nginx honestly looks for a file on disk matching the route name and does not find one.
Comparing Routing Approaches in Nginx
| Approach | Where it is set | Flexibility | Typical case |
|---|---|---|---|
| try_files | Inside location / | Medium, order is fixed | Serving static files with a fallback to index.php |
| rewrite | Inside server or location | High, regular expressions | Legacy links, pretty URLs without a framework |
| return | Inside server or location | Low, a direct response | Redirects, dropping the connection (444) |
| proxy_pass | Inside location | High, full forwarding to a backend | Node.js, Python, reverse proxying |
A deeper look at location block priority in Nginx, and which block wins when rules overlap, is in the article on location priority in Nginx (it also walks through examples of configuration conflicts).
When a Bare Nginx Config Is Not Enough
- The site needs HTTPS — the certificate and the HTTP-to-HTTPS redirect are added separately, covered in the article on installing SSL with Certbot.
- Many identical sites run on one server — instead of copying the config for every domain, it is more convenient to move shared blocks into
includefiles. - You need protection against brute-forcing or DDoS at the request level — a plain
serverblock will not help here, you needlimit_req,limit_conn, or an external WAF. - The backend is not PHP-FPM but a separate application on its own port (Node.js, Python, Go) — instead of
fastcgi_pass, you useproxy_passwith its own set of headers.
Verifying the Result
- Check the syntax before every change:
nginx -tshould returnsyntax is okandtest is successful. - Open the site by domain and by IP separately — if it works by domain, but the IP shows someone else's site,
server_nameis set up correctly; if the IP shows this same site without adefault_server, it is worth checking why. - Check the response headers:
curl -I http://yourdomain.com— aContent-Encoding: gzipheader confirms compression is actually working, not just enabled in the config. - Watch the access log for 404s and 502s right after deployment:
tail -f /var/log/nginx/access.logshows real user requests, not just test ones from a terminal.