Installing and configuring nginx on Debian and Ubuntu
From the apt package to the first server block with PHP-FPM and HTTPS: which nginx version each distribution ships, what is different about the nginx.org repository, and how to get rid of the typical errors.
Installing nginx takes thirty seconds. The rest of the day goes into finding out why the server block never matches, why PHP throws a 502 or why Apache is already sitting on port 80. This article walks through exactly those places, separately for Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04, because the four systems behave differently in several respects.
Which nginx: distribution package or official repository
The first decision comes before the first command. Each distribution ships a frozen version that only receives security patches. As of July 2026 the picture looks like this:
| System | nginx from the distribution package |
| Debian 13 (trixie) | 1.26.3 |
| Debian 12 (bookworm) | 1.22.1 |
| Ubuntu 24.04 LTS (noble) | 1.24.0 |
| Ubuntu 22.04 LTS (jammy) | 1.18.0 |
nginx.org itself offers the 1.30.4 (stable) and 1.31.3 (mainline) branches in July 2026. That leaves Ubuntu 22.04 roughly six years of feature development behind.
Take the distribution package if you run classic websites or reverse proxy setups and want unattended-upgrades to do the work for you. Take the nginx.org repository if you need HTTP/3 and QUIC (not available at all in Ubuntu 22.04, and not in Debian 12 either), if you want ready-made dynamic modules such as nginx-module-brotli or the ACME module, or if you want to run exactly the same version across several distributions.
What many guides leave out: the two packages are not the same program in different versions, they are built differently and packaged differently. That is the most common reason why a copied guide does not work.
| Property | Debian/Ubuntu package | nginx.org package |
| Process user | www-data | nginx |
| Default docroot | /var/www/html | /usr/share/nginx/html |
| sites-available and sites-enabled | present | does not exist |
| /etc/nginx/snippets/ | present | does not exist |
| ufw profiles (Nginx Full etc.) | present | does not exist |
| Dynamic modules | libnginx-mod-* | nginx-module-* |
Installing from the distribution package
Identical on all four systems:
apt update
apt install -y nginx
apt install -y curl
The nginx metapackage is enough. nginx-full and nginx-extras still exist in Debian 12 and 13, but they only pull in additional module packages. Install individual modules on purpose, for example apt install libnginx-mod-http-headers-more-filter.
curl is in that list deliberately. It is not a dependency of nginx and is missing on a freshly installed Debian or Ubuntu, even after nginx itself installed cleanly. Without this step the first verification command below stops with curl: command not found, and the same trap hits the host header test and the PHP test later on.
Now the part most guides skip: proving that it really runs. An apt install without an error message proves nothing at all.
nginx -v
systemctl is-enabled nginx
systemctl is-active nginx
curl -I http://127.0.0.1/
Expect enabled, active and an HTTP/1.1 200 OK with a Server header that names nginx (Debian 13 answers with Server: nginx, Ubuntu 24.04 with Server: nginx/1.24.0). Only then is the web server actually up. If you want to know which options the package was built with, use nginx -V (capital V), which also tells you whether --with-http_v3_module is included.
If ufw is active, the firewall rule is still missing. First a look at the package itself: on Ubuntu Server ufw is installed out of the box and merely inactive, on Debian it is not present at all. There the first call would answer with ufw: command not found. So install it as well, the package exists on all four systems:
apt install -y ufw
ufw app list
ufw allow 'Nginx Full'
The profiles come with the nginx-common package from the distribution: Nginx Full, Nginx HTTP and Nginx HTTPS, plus Nginx QUIC on Debian 13. Installed from the nginx.org repository, they never show up in ufw app list in the first place (see the table above).
Adding the official nginx repository
nginx.org supports bookworm, trixie, jammy and noble. apt-key is deprecated, so the key belongs in its own keyring and is referenced via signed-by.
apt install -y curl gnupg2 ca-certificates lsb-release debian-archive-keyring
On Ubuntu the last package is called ubuntu-keyring instead of debian-archive-keyring. After that:
mkdir -p /root/.gnupg && chmod 700 /root/.gnupg
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor | tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
gpg --dry-run --quiet --no-keyring --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg
The mkdir in the first line is not filler. On a freshly installed server /root/.gnupg does not exist yet, and gpg 2.4.7 from Debian 13 does not create the directory itself in this exact combination of calls. The verification command then stops with gpg: Fatal: /root/.gnupg: directory does not exist!, and it does so in the middle of the output, that is, before the fingerprints appear. If you want to see them, create the directory beforehand, or simply run gpg --list-keys once.
The last command is not decoration either, it is the actual counter check. It shows three keys, which is intended and no reason to stop: the current signing key 8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46 (signing-key-2@nginx.com), plus 573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62 and 9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3. If different fingerprints show up there, the download delivered something other than expected, and you stop right here.
Now the package source. Watch the path segment after /packages/, because nginx.org maintains two separate directory trees for Debian and Ubuntu. Under /packages/debian/dists/ there is neither noble nor jammy, the Ubuntu packages live exclusively under /packages/ubuntu/. The version below sets that segment itself and therefore runs unchanged on both families:
OS=$(. /etc/os-release; echo $ID)
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/$OS $(lsb_release -cs) nginx" | tee /etc/apt/sources.list.d/nginx.list
$ID is debian on Debian and ubuntu on Ubuntu, so the path matches the distribution automatically. If you hard-code packages/debian instead, because you are working through a Debian guide on an Ubuntu machine, echo writes the line without complaint and the error only turns up at the next apt update:
Err: http://nginx.org/packages/debian noble Release
E: The repository 'http://nginx.org/packages/debian noble Release' does not have a Release file.
On Ubuntu 22.04 the message says jammy instead of noble, the cause is the same. For the mainline branch, put mainline/ in front of the distribution name. For apt to actually prefer the nginx.org package over the distribution package, you need pinning, otherwise the wrong one wins depending on the version number:
printf 'Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n' | tee /etc/apt/preferences.d/99nginx
apt update
apt-cache policy nginx
apt install -y nginx
apt-cache policy nginx is the check before installing: the candidate has to be the nginx.org version (something like 1.30.4-1~noble) and the priority has to be the 900 from the pin file. If the distribution version is still listed there, the pinning is not taking effect and you would install the wrong package straight away.
Switching over from an existing distribution install
If the distribution package is already running, the upgrade fails. The message reads roughly like this:
dpkg: error processing archive /var/cache/apt/archives/nginx_1.30.4-1~bookworm_amd64.deb (--unpack):
trying to overwrite '/etc/nginx/mime.types', which is also in package nginx-common 1.22.1-9+deb12u9
The nginx.org package knows nothing about nginx-common, so the files collide. The clean way: back up the configuration first, then remove the distribution package, then install fresh.
tar czf /root/nginx-config-backup.tar.gz /etc/nginx
systemctl stop nginx
apt purge -y nginx nginx-common
apt install -y nginx
The fact that tar prints tar: Removing leading '/' from member names is normal and not an error. Afterwards /etc/nginx/sites-available is gone and your old vhosts only exist inside the tarball. Copy them to /etc/nginx/conf.d/ and give them the extension .conf, otherwise they are not loaded. Watch two things while doing that: include snippets/fastcgi-php.conf; does not exist in the nginx.org package, and the process user is now called nginx, which affects file permissions and PHP-FPM sockets.
Understanding sites-available and sites-enabled properly
The two-directory model is purely a Debian invention, nginx itself knows nothing about it. /etc/nginx/sites-available/ holds all configuration files, /etc/nginx/sites-enabled/ holds symlinks to the active ones. Only what is listed in nginx.conf via include gets loaded. When in doubt, check it yourself:
grep include /etc/nginx/nginx.conf
In the Debian and Ubuntu package there are two lines there, include /etc/nginx/conf.d/*.conf; and include /etc/nginx/sites-enabled/*;. In the nginx.org package only the first one is present. That is exactly where the classic problem comes from: somebody follows an Ubuntu guide on an nginx.org package, creates /etc/nginx/sites-available/my-site, sets the symlink, gets a clean syntax is ok from nginx -t, and still nothing happens. There is no error message, because nobody is looking at that directory in the first place.
The counter test that always tells the truth is nginx -T with a capital T. It prints the complete resolved configuration, that is, exactly what nginx really sees:
nginx -T | grep -n "server_name\|listen\|root"
If your server_name does not appear there, the file is not being read. Full stop. Any further debugging of the configuration itself is a waste of time.
The second common mistake is a symlink pointing nowhere, after a typo for instance, or because the target file was renamed:
nginx: [emerg] open() "/etc/nginx/sites-enabled/my-site" failed (2: No such file or directory) in /etc/nginx/nginx.conf:62
You find broken symlinks with find /etc/nginx/sites-enabled/ -xtype l. And a site is never disabled by deleting it in sites-available, but by removing the symlink with unlink /etc/nginx/sites-enabled/my-site.
Your first own server block
Let us set up a static site. First the directory and a test file:
mkdir -p /var/www/example/html
echo '<h1>KernelHost test page</h1>' > /var/www/example/html/index.html
chown -R www-data:www-data /var/www/example
With the nginx.org package the owner is nginx:nginx. Now the configuration, and really as a separate step: the file has to exist before the symlink points at it. Create it in an editor or write it directly with a heredoc; with the nginx.org package it goes to /etc/nginx/conf.d/example.conf instead:
cat > /etc/nginx/sites-available/example <<'EOF'
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example/html;
index index.html;
access_log /var/log/nginx/example.access.log;
error_log /var/log/nginx/example.error.log;
location / {
try_files $uri $uri/ =404;
}
}
EOF
The single quotes around 'EOF' matter, otherwise the shell replaces $uri with nothing and the block is already broken the moment you save it. Only after that: enable, test, reload.
ln -s /etc/nginx/sites-available/example /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
The order is not arbitrary. ln creates the symlink even when the target file does not exist at all, without saying a word about it. It only comes to light during the test:
nginx: [emerg] open() "/etc/nginx/sites-enabled/example" failed (2: No such file or directory) in /etc/nginx/nginx.conf:61
The line number differs per system (61 on Debian 13, 60 on Debian 12 and on both Ubuntu releases), the message is identical. A reload would be rejected in this state, so the running configuration stays untouched. This is exactly the case that find /etc/nginx/sites-enabled/ -xtype l catches as well.
Three more errors keep showing up at this point.
Duplicate default server. If you copy default_server along from a guide while the Debian default site is still active, you get:
nginx: [emerg] a duplicate default server for 0.0.0.0:80 in /etc/nginx/sites-enabled/example:2
Either leave the keyword out or switch off the default site with unlink /etc/nginx/sites-enabled/default.
Duplicate server name. If the same name appears in two blocks, the one loaded first wins without comment, and there is only a warning:
nginx: [warn] conflicting server name "example.com" on 0.0.0.0:80, ignored
Server name too long. With long domains or many subdomains:
nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 32
The remedy is server_names_hash_bucket_size 64; in the http block of nginx.conf.
You can prove it works without DNS by going through the host header:
curl -H 'Host: example.com' -sS http://127.0.0.1/
If the Debian default page comes back instead of your test page, your block is not matching and you end up in the default server. If your page comes back, everything is fine.
Wiring up PHP-FPM
This is where the nastiest trap of the whole guide waits, and it costs plenty of people an hour. The php metapackage depends, through php8.x, on the alternative libapache2-mod-php8.x | php8.x-fpm | php8.x-cgi. apt always picks the first one, so apt install php reliably installs Apache along with it, and Apache then occupies port 80. Install the specific packages instead:
apt install -y php-fpm php-mysql php-xml php-curl php-mbstring php-zip
Which PHP version you get and what the socket is called depends on the distribution:
| System | PHP | Socket | Service |
| Debian 13 | 8.4 | /run/php/php8.4-fpm.sock | php8.4-fpm |
| Debian 12 | 8.2 | /run/php/php8.2-fpm.sock | php8.2-fpm |
| Ubuntu 24.04 | 8.3 | /run/php/php8.3-fpm.sock | php8.3-fpm |
| Ubuntu 22.04 | 8.1 | /run/php/php8.1-fpm.sock | php8.1-fpm |
Do not guess the path, read it off:
ls -l /run/php/
The PHP block inside the server (Debian 12 as the example, adjust the path):
index index.php index.html;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.(?!well-known).* {
deny all;
}
On Debian and Ubuntu you can replace the first lines with include snippets/fastcgi-php.conf;. The nginx.org package does not have that snippet, so there you need the spelled out version. The try_files $uri =404; is not a stylistic detail, it prevents uploaded files with .php appended in the path from being executed.
One step is still missing though, and it gets skipped almost every time. The test call coming up runs through the default vhost that ships with the package, and in /etc/nginx/sites-available/default the location ~ \.php$ section is completely commented out by default. As long as that stays the case, nginx hands .php files through unchanged: you get a 200 OK with Content-Type: application/octet-stream and the PHP source code in plain text. That is not a cosmetic flaw. In a real file this is where database credentials or API keys sit, and everyone who knows the URL gets to read them.
So arm the block first. In /etc/nginx/sites-available/default, remove the comment characters in front of it or enter it spelled out:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
The socket name depends on the distribution, the table above lists it: php8.4 on Debian 13, php8.3 on Ubuntu 24.04, php8.2 on Debian 12, php8.1 on Ubuntu 22.04. Then test and reload:
nginx -t
systemctl reload nginx
Only now does the proof carry any weight that PHP really runs through FPM and that the file is not just offered as a download:
printf '<?php echo "PHP ", PHP_VERSION, " via ", php_sapi_name(), "\n";' > /var/www/html/kh-check.php
curl -s http://127.0.0.1/kh-check.php
rm /var/www/html/kh-check.php
The correct output looks like PHP 8.4.23 via fpm-fcgi, or PHP 8.3.6 via fpm-fcgi on Ubuntu 24.04. If the source code comes back instead, location ~ \.php$ in the active vhost is not taking effect yet, so it is either still commented out or points at the wrong socket. Do not forget to delete the file, and do not use phpinfo() on a server that is reachable from outside.
Reading a 502 Bad Gateway properly
A 502 is not a diagnosis, the diagnosis is in /var/log/nginx/error.log. There are three typical lines:
connect() to unix:/run/php/php8.2-fpm.sock failed (2: No such file or directory) while connecting to upstream
Wrong path, or FPM is not running. Check with systemctl status php8.2-fpm and ls /run/php/. A common trigger is a distribution upgrade: after the jump from Debian 12 to 13 the old configuration still points at php8.2-fpm.sock, while what is installed is 8.4.
connect() to unix:/run/php/php8.2-fpm.sock failed (13: Permission denied) while connecting to upstream
This almost exclusively hits installations from the nginx.org repository. The FPM pool belongs to www-data and has mode 0660, but nginx runs as the user nginx there. Set listen.group = nginx in /etc/php/8.2/fpm/pool.d/www.conf and restart FPM.
FastCGI sent in stderr: "Primary script unknown" while reading response header from upstream
SCRIPT_FILENAME points nowhere. Usually root sits in the location block instead of the server block, or it is missing entirely.
HTTPS with Let's Encrypt
The certbot versions of the distributions are far apart: Debian 13 ships 4.0.0, Ubuntu 24.04 ships 2.9.0, Debian 12 ships 2.1.0 and Ubuntu 22.04 only 1.21.0. They all speak ACMEv2, but 1.21 is too old for newer features such as short-lived certificates. On Ubuntu 22.04 the detour via snap is worth it.
apt install -y certbot python3-certbot-nginx
certbot --version
Three conditions have to be met before issuing, otherwise validation fails without a usable message: the A record (and the AAAA record where applicable) points at the server, port 80 is reachable from outside, and a server block with a matching server_name exists. The last point is the decisive one, because the nginx plugin finds the block through server_name, not through the file name. Then:
certbot --nginx -d example.com -d www.example.com
certbot then writes into your configuration file: a second server block with listen 443 ssl, the paths to fullchain.pem and privkey.pem, an include /etc/letsencrypt/options-ssl-nginx.conf and a redirect from port 80. The fact that the file gets modified takes people by surprise time and again. If you overwrite it by hand afterwards, you lose HTTPS and get this at the next reload:
nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/example.com/fullchain.pem": BIO_new_file() failed
Automatic renewal runs through a systemd timer, not through a cron job. Check both:
systemctl list-timers | grep certbot
certbot renew --dry-run
The dry run is the only real proof that renewal will work in ninety days. If you use the nginx.org repository, nginx 1.29 and later also offer nginx-module-acme, a variant entirely without certbot in which nginx requests and renews the certificates itself.
nginx -t, reload and restart
The rule is simple and still gets broken constantly: never reload without running nginx -t first. On a syntax error nginx does refuse to adopt the broken configuration, but with a restart the old process has already been stopped and the site is offline.
nginx -t
This is exactly what you should see:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
reload starts new workers with the new configuration and lets the old ones finish serving their open connections. Not a single request is lost. You only need restart when something about the process itself changes, for example after a package update, with a changed user or when loading load_module directives.
When the start fails, the systemd output is almost useless:
Job for nginx.service failed because the control process exited with error code.
See "systemctl status nginx.service" and "journalctl -xeu nginx.service" for details.
The usable text is one level deeper:
journalctl -xeu nginx.service --no-pager -n 30
Solving the port conflict with Apache on port 80
The best known nginx error of them all:
nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Note that nginx -t does not catch this error, the test binds no ports. Only the start fails. Find out who is holding the port first instead of guessing:
apt install -y iproute2
ss -tlnp | grep ':80'
The output names the process in plain text, typically users:(("apache2",pid=612,fd=4)). In nine out of ten cases Apache came onto the system through apt install php or through a hosting control panel. There are three clean ways out.
First, switch Apache off. If you do not need it, disabling it is enough so that it does not come back after the next reboot:
systemctl disable --now apache2
systemctl start nginx
Second, remove Apache. Careful: if libapache2-mod-php depends on it, apt purge apache2 may take PHP with it. Check the list that apt shows before executing, and install php-fpm afterwards.
Third, run both in parallel. That makes sense when existing Apache vhosts with .htaccess are supposed to keep running and nginx works in front of them as a reverse proxy. For that, put a Listen 127.0.0.1:8080 into /etc/apache2/ports.conf, change <VirtualHost *:80> to <VirtualHost *:8080> in every vhost and let nginx forward:
location / {
proxy_pass http://127.0.0.1:8080;
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;
}
So that Apache no longer sees just 127.0.0.1 in its logs, enable the remoteip module there. Without this step, Fail2ban rules on Apache logs are worthless, because every request appears to come from localhost.
Two variants of the same error are easily overlooked. If the same listen entry appears twice across two of your own files, nginx reports Address already in use as well, even though no Apache is involved at all. And with bind() to [::]:80 failed only IPv6 is affected, usually because a second block also sets listen [::]:80 without ipv6only=on.
The sign-off: eight checks instead of gut feeling
Before you consider an installation finished, work through these points. Every single one of them has prevented a silent outage at some point.
nginx -treports test is successful.systemctl is-enabled nginxreturnsenabled, so the service comes back after a reboot.ss -tlnp | grep nginxshows port 80 and, if set up, port 443.nginx -T | grep server_namelists every domain that is supposed to run.curl -I http://127.0.0.1/returns a 200 or an intended redirect.- The PHP test file prints
fpm-fcgiand has been deleted afterwards. certbot renew --dry-runcompletes without errors./var/log/nginx/error.logcontains no new entries after a test request.
On a freshly installed server the firewall belongs on top of that, see our guide on setting up ufw. Debian 10 and Ubuntu 20.04 have been out of support since June 2024 and May 2025 respectively and no longer receive nginx security updates, so an upgrade there is not a question of comfort, it is overdue.
What KernelHost adds
On the KVM root servers and dedicated servers from KernelHost you install Debian 13, Debian 12, Ubuntu 24.04 or Ubuntu 22.04 straight from the customer panel and then have full root access, so the steps described above run unchanged. The systems are located in the maincubes datacenter in Frankfurt am Main, TÜV certified to TIER3+, connected through our own network. DDoS protection takes effect in front of the server and not just inside nginx: 3.2 Tbps of Arbor real-time filtering directly on site, plus up to 17 Tbps of global filtering capacity in the Professional plans. Everything runs on a PrePaid basis, so no minimum term, no notice period and no setup fee.
Frequently asked questions
Should I install nginx from the distribution package or from the official nginx.org repository?
Why is my server block in sites-available ignored completely even though nginx -t succeeds?
nginx does not start and reports bind() to 0.0.0.0:80 failed (98: Address already in use). What now?
Which PHP version and which FPM socket do I need on my distribution?
How do I reliably tell that nginx really runs correctly and that the command did not just complete?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

