Installing Nginx on Ubuntu and putting a website on it takes ten minutes in practice — and, in many people's minds, a whole afternoon. The only difference is knowing the right order of steps: install the web server, create the site folder with the right permissions, write the server block, add a free SSL certificate, and apply a few small optimizations. We'll follow exactly that order, with the real output of every command and the spots where people get stuck clearly marked.
Prerequisites: what do you need before you start?
- An Ubuntu server (22.04, 24.04 or 26.04) with root access;
- SSH access with a user that has sudo rights (full guide: connecting to a server over SSH);
- A domain whose A record points to the server's IP address (not sure how? See pointing a domain to a VPS).
Take the third one seriously: a large share of SSL certificate failures come from exactly this — the domain doesn't resolve to the server's IP yet. Test it from the server itself. dig isn't part of a standard install; it comes with sudo apt install bind9-dnsutils -y:
dig +short example.ir
203.0.113.45
If the output is empty or a different IP comes back, it isn't time to continue yet: fix the A record and wait for the TTL to run out (in Mehran Host's managed DNS the minimum TTL is sixty seconds).
Which Ubuntu version? Differences that affect your configuration
The release you choose decides which Nginx and PHP versions you get and — the point that catches many people out — which directive turns on HTTP/2:
| Ubuntu release | Nginx in the repository | Default PHP | How to enable HTTP/2 | End of standard support |
|---|---|---|---|---|
| 22.04 LTS | 1.18.0 | 8.1 | listen 443 ssl http2; | May 2027 |
| 24.04 LTS | 1.24.0 | 8.3 | listen 443 ssl http2; | May 2029 |
| 26.04 LTS | 1.28.3 | 8.5 | The standalone http2 on; directive | May 2031 |
The fourth column is the source of one of the most common points of confusion: as of 1.25.1, Nginx deprecated the http2 parameter on listen and replaced it with http2 on;. So on 26.04, writing listen 443 ssl http2; produces a warning, while http2 on; breaks the configuration on the two older releases.
For HTTP/3, the version is decisive. It isn't available on 22.04 or 24.04: 1.18 and 1.24 are older than 1.25.0, where QUIC support began, and the 24.04 package wasn't built with the --with-http_v3_module flag. On 26.04 the tables have turned: Debian has enabled that flag since 1.26.0-2 and the 1.28.3 package includes it — so you don't need nginx.org's own mainline repository to get h3. Upstream still calls it experimental, so check your own package; empty output means it isn't available:
nginx -V 2>&1 | grep -o with-http_v3_module
Step 1: Install Nginx on Ubuntu
sudo apt update && sudo apt install nginx -y
sudo systemctl enable --now nginx
The second line is reassurance, not a necessity: the Ubuntu package starts the service and enables it at boot on its own. In cloud images and containers that block automatic starts with policy-rc.d, however, that doesn't happen:
systemctl status nginx --no-pager | head -6
● nginx.service - A high performance web server and a reverse proxy server
Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-09-07 10:12:41 UTC; 8s ago
Docs: man:nginx(8)
Main PID: 1841 (nginx)
Tasks: 3 (limit: 4613)
(On 22.04 the unit path is /lib/systemd/system/.) Open the server's IP in a browser: the "Welcome to nginx" page means the web server is alive. If you have UFW enabled:
sudo ufw allow 'Nginx Full'
Rule added
Rule added (v6)
sudo ufw status
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
OpenSSH (v6) ALLOW Anywhere (v6)
Nginx Full (v6) ALLOW Anywhere (v6)
The Nginx Full profile opens both ports 80 and 443 (Nginx HTTP opens only 80, Nginx HTTPS only 443). Resist the temptation to close port 80 for the sake of being "more secure"; issuing the free certificate depends on it.
Step 2: The site folder, and permissions that won't cause trouble later
sudo mkdir -p /var/www/example.ir/html
sudo tee /var/www/example.ir/html/index.html >/dev/null <<'EOF'
<!doctype html>
<meta charset="utf-8">
<h1>Hello, world! 🚀</h1>
EOF
Replace example.ir with your own domain. The /var/www/domain/html structure isn't mandatory, but it's a good convention: side folders such as application logs sit next to html, outside the web root.
Now for ownership and permissions — the part many tutorials skip over with a chmod 777, a permission that is no longer configuration but an invitation. The right rule: the web server's user only reads, and someone else owns the files:
sudo chown -R $USER:www-data /var/www/example.ir
sudo find /var/www/example.ir -type d -exec chmod 750 {} \;
sudo find /var/www/example.ir -type f -exec chmod 640 {} \;
You are the owner and you write; the www-data group — the user that the Nginx and PHP-FPM workers run as — only reads. Tutorials that hand the whole site over to www-data throw away exactly this protection: when the web server owns the files, any PHP script — including an uploaded web shell — can rewrite the site's code. If the application needs to write somewhere, make an exception for that one folder only:
sudo chown -R www-data:www-data /var/www/example.ir/html/wp-content/uploads
Step 3: Define the site with an Nginx server block
Nginx on Ubuntu follows a two-folder convention: site configurations are written in /etc/nginx/sites-available/ and enabled with a symbolic link in /etc/nginx/sites-enabled/ — so temporarily disabling a site is just a matter of removing a link. Write the block below with sudo nano /etc/nginx/sites-available/example.ir; naming the file after the domain is a convention, not a requirement:
server {
listen 80;
listen [::]:80;
server_name example.ir www.example.ir;
root /var/www/example.ir/html;
index index.html index.php;
charset utf-8;
access_log /var/log/nginx/example.ir.access.log;
error_log /var/log/nginx/example.ir.error.log;
location / {
try_files $uri $uri/ =404;
}
}
Three points. The listen [::]:80; line opens the same port on IPv6 as well; without it, IPv6 visitors never reach your block. As long as the default site is enabled they get that "Welcome to nginx" page; otherwise the browser falls back to IPv4 after a pause — so you see the wrong site or a slow one, not a clear error. server_name decides which names the block answers to, which is why both the www and non-www forms are listed. And take charset utf-8; seriously: by default Nginx declares no charset in the Content-Type header. An HTML page with a meta charset tag is fine, but a .txt file, raw PHP output or a JSON response without an explicit header is decoded by the browser's guess, and non-Latin text such as Persian comes out garbled.
Now enable the site with a symbolic link and test the configuration:
sudo ln -s /etc/nginx/sites-available/example.ir /etc/nginx/sites-enabled/
sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx
Make nginx -t a habit; it exposes a broken configuration before it is applied. There is also a difference between reload and restart: the first hands the new configuration to the workers without dropping connections in progress, while the second takes the service down and brings it back up.
Step 4: Free HTTPS with Let's Encrypt
Let's Encrypt issues certificates trusted by every browser free of charge, and its official automation tool is called Certbot. You have two ways to install it:
# the simple way — from Ubuntu's own repository
sudo apt install certbot python3-certbot-nginx -y
# the project's own recommended way — always up to date
# if you already installed the apt version, remove it first, as Certbot's official instructions say
sudo apt-get remove certbot -y
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
The difference is how recent the version is: the Ubuntu 24.04 package is frozen at 2.9.0, whereas the snap updates itself, and the Certbot team officially recommends it. That removal line isn't a formality either: with both installed, the apt version's scheduler runs separately against the same /etc/letsencrypt, and two copies of Certbot go after the same certificate. Then:
sudo certbot --nginx -d example.ir -d www.example.ir
Certbot asks for an email address and for you to accept the terms, then runs HTTP-01 validation: it places a temporary file under /.well-known/acme-challenge/, which Let's Encrypt's servers read over port 80. It then installs the certificate, rewrites the block for port 443 and sets up the HTTP-to-HTTPS redirect:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.ir/fullchain.pem
Key is saved at: /etc/letsencrypt/live/example.ir/privkey.pem
This certificate expires on 2026-12-06.
These files will be updated when the certificate renews.
Deploying certificate
Successfully deployed certificate for example.ir to /etc/nginx/sites-enabled/example.ir
Successfully deployed certificate for www.example.ir to /etc/nginx/sites-enabled/example.ir
Count the last lines: you should see one Successfully deployed line for every name you passed with -d. If one is missing, that name wasn't validated.
Let's Encrypt has rate limits that fill up quickly during trial and error: 50 certificates per registered domain every seven days, 5 certificates for exactly the same set of names every seven days, and 5 failed validations per name per hour. So use --dry-run while testing; it doesn't burn through your quota. Wildcard certificates and DNS validation are covered in the complete guide to installing a free SSL certificate with Certbot.
What Certbot won't do for you: enable HTTP/2 in Nginx
Certbot builds the 443 block but doesn't turn on HTTP/2; you have to add it yourself, in the form shown in the table above: on 26.04 an http2 on; line inside the 443 block, and on older releases the parameter attached to listen. Then run nginx -t, reload, and confirm:
curl -sI --http2 https://example.ir | head -1
HTTP/2 200
Let's Encrypt auto-renewal: where does it silently break?
Let's Encrypt certificates are valid for ninety days until February 10, 2027, and for 64 days after that, and they are supposed to renew without any action from you. During installation Certbot creates a timer that runs twice a day and renews each certificate once a third of its lifetime is left:
systemctl list-timers | grep certbot
Mon 2026-09-07 21:47:00 UTC 11h left Mon 2026-09-07 09:12:03 UTC 1h ago snap.certbot.renew.timer snap.certbot.renew.service
sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.ir/fullchain.pem (success)
The unit name depends on how you installed Certbot: with snap the timer is snap.certbot.renew.timer, and with apt it is certbot.timer. If you see neither, there is no automatic renewal.
Now the more important part: why this mechanism sometimes breaks without a sound. Let's Encrypt shut down its expiry-warning email service in June 2025, so the old safety net is gone. If a renewal fails one night, nobody tells you — until your visitors see the browser's red warning.
The causes are preventable: port 80 closed on the firewall at some later point; an A record changed during a server move while the certificate is still being renewed on the old server; or a block edited by hand that sends /.well-known/ into a site-wide redirect. Add certificate expiry to your server monitoring:
echo | openssl s_client -connect example.ir:443 -servername example.ir 2>/dev/null \
| openssl x509 -noout -enddate
notAfter=Dec 6 08:14:22 2026 GMT
Step 5 (optional): Nginx PHP-FPM config for dynamic sites
So far the site serves only static files. Unlike Apache, Nginx doesn't run PHP itself; it hands the request over FastCGI to a separate service called PHP-FPM:
sudo apt install php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-xml php8.3-curl -y
systemctl status php8.3-fpm --no-pager | head -3
● php8.3-fpm.service - The PHP 8.3 FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/php8.3-fpm.service; enabled)
Active: active (running)
Pick the version that matches your Ubuntu release (see the table above) and add this inside the server block:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
The ready-made snippets/fastcgi-php.conf snippet isn't just boilerplate: besides the standard FastCGI parameters, it contains the line try_files $fastcgi_script_name =404;, which makes sure the PHP file actually exists before the request is passed to the interpreter. That single line closes off a family of old attacks that executed arbitrary scripts through manipulated paths.
Three settings you'll come back to later
Two of Nginx's conservative defaults show themselves as soon as a real application arrives: the one-megabyte cap on the request body — larger uploads are rejected with HTTP 413 — and the sixty-second timeout for FastCGI responses. That second limit has a rival, though. PHP's max_execution_time defaults to thirty seconds, but on Linux it counts only the time the script itself spends executing; database queries, API calls and socket reads don't count toward it. So a CPU-heavy loop hits PHP's limit first and returns HTTP 500, but a slow query — the real cause of most 504s — doesn't advance PHP's timer, and after sixty seconds you get a 504 from Nginx.
client_max_body_size 32m;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 120s;
}
The same rule holds for uploads: the limit is defined in two places — client_max_body_size in Nginx, and upload_max_filesize and post_max_size in /etc/php/8.3/fpm/php.ini — and the stricter one wins. For the 120-second timeout, set max_execution_time = 120 in that same file and reload PHP-FPM separately; reloading Nginx isn't enough.
WordPress and single-entry apps: the Nginx try_files rule
If you install WordPress or any modern PHP framework, the home page will probably work while every internal link returns a 404. The configuration from step 3 said "if there's no file or folder at this address, return 404", and an address like /blog/nginx-setup/ has no file on disk; WordPress has to build that page through index.php. Apache does this with .htaccess, which Nginx doesn't read:
location / {
try_files $uri $uri/ /index.php?$args;
}
In plain words: first look for a file with this name, then a folder with this name, and if neither exists, hand the request to index.php with the same query parameters. For Laravel the same pattern works with the public folder as the root. For a complete installation, follow our guide to installing WordPress on a server.
Three small optimizations with a big effect
Enable gzip compression
It is usually already enabled in /etc/nginx/nginx.conf; make sure these lines are uncommented:
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types text/css application/javascript application/json image/svg+xml;
Text files shrink dramatically — that means better speed and less outbound traffic, which is billed separately by the gigabyte. The ngx_brotli module usually goes a step further. Two notes: text/html doesn't need to be listed because Nginx always compresses it, and gzip_vary on; sends the Vary: Accept-Encoding header so intermediate caches don't mix up the two versions. Leave images and video out; they are already compressed.
Caching static files
location ~* \.(css|js|png|jpg|jpeg|gif|svg|webp|woff2)$ {
add_header Cache-Control "public, max-age=2592000, immutable";
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header Referrer-Policy strict-origin-when-cross-origin;
add_header Strict-Transport-Security "max-age=300" always;
access_log off;
}
This block tells the browser not to come back to the server for thirty days, and immutable goes one step further: within that period, don't even send a revalidation request. The prerequisite is versioned file URLs; if you keep overwriting the same style.css, returning visitors will see the stale version for up to a month.
Two notes about the block. We deliberately didn't write expires 30d;: it generates its own Cache-Control header, and together with add_header the response would carry two contradictory headers. And the four security headers are repeated verbatim — because of a trap that comes a few lines further down.
To take this caching closer to your users: if you have delegated your domain's nameservers to Mehran Host, you can switch on Mehran Host's CDN with a single toggle next to that same A record (the first 1 TB of CDN traffic is free). Edge nodes in Iran and Europe hold the static files, and your server carries only the dynamic load.
Nginx security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header Referrer-Policy strict-origin-when-cross-origin;
add_header Strict-Transport-Security "max-age=300" always;
The first three restrict, in order, content-type sniffing, embedding your site in another site's frame, and information leaking through the Referer header. The fourth — HSTS — tells the browser to open this domain only over HTTPS.
Quick troubleshooting: symptom, cause, first action
Before any guessing, run sudo nginx -t and watch the error log live with sudo tail -f /var/log/nginx/error.log:
| Symptom | Likely cause | First action |
|---|---|---|
| 502 Bad Gateway | PHP-FPM is down, or the socket path doesn't match the PHP version | systemctl status php8.3-fpm and compare fastcgi_pass with the contents of /run/php/ |
| 403 Forbidden | Folder ownership or permissions, or a missing index file | Review with ls -l and make sure the www-data group can read |
| 404 on every internal page | No rewrite rule for a single-entry application | Add try_files ending in index.php to location / |
| PHP files download instead of running | The location ~ \.php$ block is missing | Add the FastCGI block and reload Nginx |
| The default Nginx page | The sites-enabled link wasn't created, or server_name doesn't match | ls -l /etc/nginx/sites-enabled/ and delete the default file |
| 413 Request Entity Too Large | The default one-megabyte request body cap | Raise client_max_body_size and, at the same time, upload_max_filesize in php.ini |
| 504 Gateway Time-out | Nginx's sixty-second timeout was exceeded; if you get a 500 instead, you've hit PHP's own thirty-second limit | Raise fastcgi_read_timeout and max_execution_time together |
| Certbot fails at the challenge stage | Port 80 closed, a wrong A record, or a site-wide redirect | sudo ufw status and dig +short example.ir |
| Security headers missing on some paths | add_header inheritance broken in a nested block | Repeat the headers in that same location block |
The 502 error comes up so often that we wrote a separate guide for it: tracking down a 502 Bad Gateway error step by step.
Done — your site is live
You now have a real web server with automatic HTTPS: a clean server block, monitored renewal, PHP-FPM connected, and compression and caching switched on.
Three jobs remain for this first day: install unattended-upgrades for automatic security patches; get backups of /var/www, /etc/nginx and the database off the server, with a test restore — a backup sitting next to the data itself is no backup at all; and record the site's current state with Mehran Host's website speed test. This tool sends its requests from the panel's server outside Iran, not from a home connection inside Iran, so read the number as a before-and-after comparison for yourself, not as the experience of a visitor in Iran.
The next step is hardening the server itself: SSH keys instead of passwords, no direct root login, and a firewall that leaves only the necessary ports open — all covered in the Linux server hardening checklist. If this route feels like too much, on shared hosting with cPanel or DirectAdmin these layers come preconfigured.
Frequently asked questions
What do I need before setting up a website with Nginx on Ubuntu?
You need three things: an Ubuntu server running 22.04 or later, SSH access with a user that has sudo rights, and a domain whose A record points to the server's IP. Use dig to make sure the domain really resolves to that IP; otherwise the SSL certificate step will fail. With a correct A record, the whole process from installation to working HTTPS takes about ten minutes.
What is the difference between Nginx and Apache for a simple website?
The biggest practical difference is that Nginx doesn't read .htaccess files; every URL rewrite rule has to be written in the server block and applied with a reload. Nginx also doesn't run PHP itself and passes requests to PHP-FPM over FastCGI, whereas Apache has long been able to run PHP as a module inside itself — although on current Ubuntu the default event MPM doesn't work with mod_php, and the usual advice there is PHP-FPM too.
Why does Certbot fail to issue an SSL certificate?
Three causes account for most failures: the domain doesn't point to the server's IP yet, port 80 is closed on the firewall, or a site-wide redirect diverts the validation path. HTTP-01 validation requires Let's Encrypt's servers to read a file under the .well-known path over port 80. Each name is allowed only five failed validations per hour, so use a dry run while you are testing.
How often do Let's Encrypt certificates renew, and what happens if renewal fails?
Certificates are valid for ninety days until February 2027, after which the Let's Encrypt default becomes 64 days, and Certbot's automatic timer renews each one when about a third of its lifetime remains. If that process breaks you get no warning, because Let's Encrypt shut down its expiry-notification emails in June 2025. The result is a red browser warning for every visitor to your site.
Why do inner pages return 404 after installing WordPress on Nginx?
Because no URL rewrite rule has been defined. On Apache, WordPress gets this rule from its .htaccess file, which Nginx doesn't read, so the address of a post that has no file on disk ends in a 404. The fix is a try_files line ending in index.php in the main location block, followed by an Nginx reload. The same pattern works for Laravel and single-page apps.