Web Server with Nginx

Web Server with Nginx

Bitnesia Aug 28, 2026 2 ID

The zone file example.local that we created in Chapter 9 already stores the record www IN A 192.168.1.20, so that domain name is already pointing to the correct address. The problem is, a correct DNS does not automatically mean there is a service running at that address. Prove it yourself by running the following command from another computer on the same network.

curl http://192.168.1.20

The result is not a web page, but a message:

curl: (7) Failed to connect to 192.168.1.20 port 80: Connection refused

The DNS is indeed correct, but there is no process listening on port 80 at that IP yet. Our next task is to start the software that actually responds to HTTP requests at that address, and Nginx is the answer. This chapter opens Part IV of the course, a section that specifically discusses web services.

We will start from the installation and configuration directory structure of Nginx, then create a virtual host (server block) for the example.local domain already registered in the DNS. After that, we will practice how to serve static content and the basics of reverse proxy to backend applications, before closing the chapter by learning how to read access logs and error logs for daily verification and troubleshooting needs.

12.1 Installation and Configuration Structure of Nginx

Nginx (pronounced "engine-x") is a web server as well as a reverse proxy originally written by Igor Sysoev to solve the C10K problem, which is the challenge of serving tens of thousands of concurrent connections without excessively overloading server resources. Unlike Apache, which traditionally creates a single process or thread for every connection, Nginx uses an event-driven architecture that is far more memory efficient when handling many connections simultaneously. This characteristic is what makes Nginx a popular choice, both as a pure web server and as a reverse proxy in front of backend applications, a topic we will delve deeper into in Chapter 16.

12.1.1 Installing Nginx from the Ubuntu Repository

Ubuntu Server provides Nginx directly in the official repository, so its installation does not differ from other packages we have installed in previous chapters.

Practical Steps

  1. Update the package list, then install Nginx.
    sudo apt update
    sudo apt install nginx
    This command actually installs nginx as a metapackage that pulls one of the variants, generally nginx-core which is lightweight and sufficient for standard web server needs. Other variants such as nginx-full or nginx-extras are only relevant if we need additional modules that are not included in the core build.
  2. Unlike chrony in Chapter 11 which was installed from the start, the Debian/Ubuntu package for Nginx automatically enables and starts its service as soon as the installation completes. Confirm its status.
    systemctl status nginx.service
  3. Open HTTP port access in UFW so visitors from outside the server can reach Nginx.
    sudo ufw allow 80/tcp
  4. Test from another computer on the same network, this time pointing to the IP of the server where Nginx is running.
    curl http://192.168.1.20
    If successful, the response will be a simple HTML page titled "Welcome to nginx!", a sign that Nginx is already serving requests with its default configuration.

Verification and Troubleshooting

  • Check the version of Nginx that is actually installed, because the exact version may differ depending on when the Ubuntu repository was last synchronized.
    nginx -v
  • If systemctl status shows that the service failed to start, the most common cause is that port 80 is already used by another process. Check with:
    sudo ss -tlnp | grep :80
    This case often happens if Apache (Chapter 13) was previously installed on the same server. Both web servers cannot run simultaneously on the same port without a special configuration.
  • If curl from another computer still fails even though the service is active (running), recheck the UFW rules.
    sudo ufw status

12.1.2 Anatomy of Nginx Configuration Directory

Before creating our own configuration, it is important to first understand how Nginx configuration files are interconnected. A misunderstanding about this structure is one of the most frequent sources of troubleshooting in the field, especially when saved changes turn out to have never actually been read by Nginx.

Practical Steps

  1. View the contents of the main configuration file.
    cat /etc/nginx/nginx.conf
    The contents are more or less as follows, although details such as worker_connections might slightly differ depending on the installed package version.
    user www-data;
    worker_processes auto;
    pid /run/nginx.pid;
    include /etc/nginx/modules-enabled/*.conf;
    
    events {
        worker_connections 768;
    }
    
    http {
        include /etc/nginx/mime.types;
        default_type application/octet-stream;
    
        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;
    
        gzip on;
    
        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
    }
    The last two include lines in the http block are the key to understanding this structure. Those lines tell Nginx to read every file in /etc/nginx/conf.d/ and every file in /etc/nginx/sites-enabled/ as part of the active configuration.
  2. View the contents of both directories.
    ls -la /etc/nginx/sites-available/
    ls -la /etc/nginx/sites-enabled/

/etc/nginx/sites-available/ contains all virtual host configuration files we have ever created, active or not, whereas /etc/nginx/sites-enabled/ only contains symbolic links pointing back to specific files in sites-available. This two-directory convention is actually not a built-in Nginx feature, but a typical Debian and Ubuntu packaging convention to make it easier for us to enable or disable a site simply by adding or removing a symlink, without needing to delete the configuration file at all. The default server block named default is already active from the start; that is what answered our curl earlier with the "Welcome to nginx!" page, and its contents are served from /var/www/html.

Verification and Troubleshooting

  • Every time you change the Nginx configuration, make it a habit to test its syntax first before applying it. We will continue using this habit throughout all sections of this chapter.
    sudo nginx -t
    A healthy output shows syntax is ok and test is successful. If there is an incorrect line, the error message usually specifies the file name and line number involved, so there is no need to guess.
  • In the field, the most common mistake made by beginners is editing a file in sites-available but forgetting to create its symlink in sites-enabled, so changes are never read even though nginx -t still reports valid syntax.

12.2 Virtual Host (Server Block)

In Nginx, a single configuration block defining how a domain is served is called a server block, a term equivalent to virtual host in Apache (Chapter 13). A server block allows a single physical server or IP address to serve multiple domains simultaneously, each with a different document root, configuration, or even backend application. Its mechanism relies on the Host header sent by the browser in every HTTP request; that is how Nginx knows which domain the visitor is requesting even though everything points to the same IP address.

12.2.1 Creating a Server Block for example.local

Now we will replace the default built-in default server block with our own server block, which actually serves pages for the example.local and www.example.local domains according to the DNS records we registered in Chapter 9.

Practical Steps

  1. Create a dedicated document root directory for this domain, separate from /var/www/html belonging to the default server block.
    sudo mkdir -p /var/www/example.local/html
  2. Create a simple index page for testing.
    echo "<h1>The example.local page is running on Nginx</h1>" | sudo tee /var/www/example.local/html/index.html
  3. Set the directory ownership to match the user used by Nginx worker processes, which is www-data, according to the user www-data; line we saw in nginx.conf.
    sudo chown -R www-data:www-data /var/www/example.local
  4. Create a new server block file in sites-available.
    sudo nano /etc/nginx/sites-available/example.local
  5. Fill it with the following configuration.
    server {
        listen 80;
        listen [::]:80;
    
        server_name example.local www.example.local;
        root /var/www/example.local/html;
        index index.html;
    
        location / {
            try_files $uri $uri/ =404;
        }
    }
    The server_name directive determines which domains are handled by this server block, root points to its document root, and try_files in the location / block instructs Nginx to search for files matching the requested $uri, trying as a directory if the file is not found, and returning 404 if both still do not exist.
  6. Enable this server block by creating a symlink to sites-enabled.
    sudo ln -s /etc/nginx/sites-available/example.local /etc/nginx/sites-enabled/
  7. Disable the default server block so it no longer acts as a confusing fallback when we add other domains later.
    sudo rm /etc/nginx/sites-enabled/default
    The original file in sites-available/default is kept as a reference; only its active symlink is removed.
  8. Test the syntax, then apply the changes.
    sudo nginx -t
    sudo systemctl reload nginx
    Use reload instead of restart for routine configuration changes like this. reload causes Nginx to re-read the configuration without interrupting ongoing connections, whereas restart shuts down all processes before starting them again, which risks causing a brief outage on production services.
  9. Test from another computer on the same network, this time via the domain name because the internal DNS from Chapter 9 already recognizes it.
    curl http://www.example.local

Verification and Troubleshooting

  • If the error [emerg] "server" directive is not allowed here appears during nginx -t, the most common cause is unbalanced curly braces in the configuration file.
  • If curl still returns the "Welcome to nginx!" page instead of the example.local page, most likely the symlink to sites-enabled/default has not been completely removed, or a reload has not been executed after the change.
  • For clients that have not synchronized their DNS cache yet, test directly to the IP with a manual Host header so you do not have to wait for propagation.
    curl -H "Host: www.example.local" http://192.168.1.20

12.2.2 Multiple Domain Name-Based Virtual Hosts

The true power of server blocks is seen when a single server serves more than one domain. This scheme is called name-based virtual hosting, because Nginx distinguishes each domain purely based on the Host header, not based on different IP addresses or port numbers.

Practical Steps

  1. Prepare the document root for the second domain, for example blog.example.local.
    sudo mkdir -p /var/www/blog.example.local/html
    echo "<h1>Blog example.local</h1>" | sudo tee /var/www/blog.example.local/html/index.html
    sudo chown -R www-data:www-data /var/www/blog.example.local
  2. Create a new server block for this domain.
    sudo nano /etc/nginx/sites-available/blog.example.local
    server {
        listen 80;
        listen [::]:80;
    
        server_name blog.example.local;
        root /var/www/blog.example.local/html;
        index index.html;
    
        location / {
            try_files $uri $uri/ =404;
        }
    }
  3. Enable, test, and apply as before.
    sudo ln -s /etc/nginx/sites-available/blog.example.local /etc/nginx/sites-enabled/
    sudo nginx -t
    sudo systemctl reload nginx
  4. The domain blog.example.local does not have a record in the zone file from Chapter 9 yet, so add a new A record following the same pattern as the existing www record, then increment the Serial number in the SOA and reload BIND9.
    blog IN  A   192.168.1.20
    For quick testing without touching the DNS server at all, curl also supports manual name resolution via the --resolve flag, suitable for when we just want to verify the server block without modifying the zone file first.
    curl --resolve blog.example.local:80:192.168.1.20 http://blog.example.local/

Verification and Troubleshooting

  • Compare the results of curl http://www.example.local and curl http://blog.example.local from the exact same server IP. Different content on both domains proves that Nginx really chooses the server block based on the Host header, rather than merely serving the same single configuration for all requests.
  • If two server blocks accidentally have the same server_name, Nginx still runs without errors during nginx -t, but only the server block defined first (based on file reading order) is actually used. This is a classic trap that often goes unnoticed because it produces no error messages whatsoever.

12.3 Serving Static Content and Basic Reverse Proxy

The server blocks we have created so far serve static files as they are. This section deepens slightly into how Nginx serves static content, and then moves into the capability that makes Nginx so popular in modern architecture: acting as a reverse proxy in front of backend applications.

12.3.1 Serving Static Content: root, alias, and Custom Error Pages

Two directives frequently confused by beginners are root and alias. The root directive appends the location path behind the filesystem path, whereas alias replaces it completely. Choosing incorrectly between the two is a common cause of confusing 404 errors even though the file actually exists on disk.

Practical Steps

  1. Create a simple custom error page for status 404.
    echo "<h1>Page not found at example.local</h1>" | sudo tee /var/www/example.local/html/404.html
  2. Add the error_page directive into the example.local server block.
    sudo nano /etc/nginx/sites-available/example.local
    error_page 404 /404.html;
    location = /404.html {
        internal;
    }
    The internal directive ensures this page can only be reached through Nginx's own internal redirect mechanism, rather than accessed directly by visitors via the URL /404.html.
  3. Apply and test.
    sudo nginx -t
    sudo systemctl reload nginx
    curl -i http://www.example.local/non-existent-page

Verification and Troubleshooting

  • On the output of curl -i, pay attention to the first status line. That line should still show HTTP/1.1 404 Not Found even though the page content is already a custom error page, rather than the default plain 404 page from Nginx.
  • In the field, a fairly common error is placing a different root directive inside a specific location block when intending to use alias. The combination root /var/www/example.local/html; location /assets { root /var/www/shared; } still appends /assets behind its root path, so the file actually searched for is /var/www/shared/assets/..., not /var/www/shared/... as is often assumed.

12.3.2 Reverse Proxy to Backend Applications

As a reverse proxy, Nginx receives requests from visitors and forwards them to a backend application running on another port, then passes the response back to the visitor as if Nginx itself answered. This pattern is very commonly used by developers for Node.js, Python, or Java applications that have their own application server, but are not designed to face public traffic directly. Chapter 16 will later expand this topic to load balancing multiple backends simultaneously, whereas here we focus first on its basic mechanism with just a single backend.

Practical Steps

  1. To test the reverse proxy mechanism without needing to deploy a real application, run Python's built-in simple HTTP server as a dummy backend listening only on localhost.
    mkdir -p ~/demo-backend && cd ~/demo-backend
    echo "Hello from backend port 8080" > index.html
    python3 -m http.server 8080 --bind 127.0.0.1
    Let this command run in a separate terminal session or tmux (Chapter 3), because the process runs in the foreground.
  2. In another terminal, add a new location block to the example.local server block that forwards requests to that backend.
    sudo nano /etc/nginx/sites-available/example.local
    location /app/ {
        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;
    }
  3. Apply and test.
    sudo nginx -t
    sudo systemctl reload nginx
    curl http://www.example.local/app/
    The response should display the contents of index.html from the Python backend earlier, even though the visitor only communicates with Nginx on port 80.

The three proxy_set_header directives above are not just a formality. Without Host, the backend receives Host: 127.0.0.1 instead of the original domain name requested by the visitor, which can break applications that construct absolute URLs based on this header. Without X-Real-IP and X-Forwarded-For, the backend application logs will only record Nginx's IP itself (127.0.0.1) as the source of all requests, rather than the actual visitor IP, a problem that only becomes noticeable when sysadmins need to trace suspicious traffic sources or brute-force attacks on the backend.

Verification and Troubleshooting

  • A 502 Bad Gateway error almost always means Nginx cannot reach the backend specified in proxy_pass. Ensure that the backend process is still running.
    ss -tlnp | grep 8080
  • Pay attention to the trailing slash (/) at the end of the path in location /app/ and proxy_pass http://127.0.0.1:8080/. The combination of slashes on both sides causes Nginx to strip the /app/ prefix before forwarding the request to the backend. If one of the slashes is omitted, the prefix gets carried over to the backend, often resulting in a confusing 404 because it looks correct in configuration but acts wrong in practice.

12.4 Access and Error Logs

Two log files are the main sources of information when we need to verify traffic or trace issues on Nginx, much faster than guessing from client-side symptoms.

12.4.1 Reading Access Logs and Error Logs

Access log records every request successfully received by Nginx, while error log records events outside normal conditions, ranging from mild warnings to proxy failures to the backend. By default, both are stored in /var/log/nginx/access.log and /var/log/nginx/error.log, according to the access_log and error_log directives we saw in nginx.conf in Section 12.1.2.

Practical Steps

  1. Monitor the access log in real-time in one terminal session.
    sudo tail -f /var/log/nginx/access.log
  2. From another terminal, send several requests to the server, then observe the new lines appearing in the access log.
    curl http://www.example.local
    curl http://www.example.local/non-existent-page
    Each line of the log is formatted more or less as follows, following the combined log format which is the de facto standard for many web servers.
    192.168.1.5 - - [26/Aug/2026:10:15:03 +0700] "GET / HTTP/1.1" 200 512 "-" "curl/8.5.0"
    The order of fields is client IP, remote user identity (usually - because it is rarely used), timestamp, complete request line, HTTP status code, response size in bytes, referer, and finally user agent.
  3. Press Ctrl+C to stop tail -f, then view the error log for comparison.
    sudo tail -n 20 /var/log/nginx/error.log

Verification and Troubleshooting

  • The status code column in the access log is the quickest starting point to assess overall traffic health roughly. A sudden stream of 5xx usually indicates server-side or backend issues, while a surge of 4xx, especially 404, often indicates broken links or an attacker randomly scanning common paths.
  • If you want to separate logs per domain to make analysis easier, add dedicated access_log and error_log directives inside a specific server block, overriding global settings in nginx.conf.
    access_log /var/log/nginx/example.local.access.log;
    error_log /var/log/nginx/example.local.error.log;

12.4.2 Error Log Levels and Log Rotation

The error_log directive accepts an optional level parameter that controls how detailed the recorded messages are, ordered from least severe to most critical: debug, info, notice, warn, error, crit, alert, and emerg. The default level is error, sufficient for daily operations, but too minimal when tracing bugs that are difficult to reproduce.

Practical Steps

  1. Temporarily raise the error log level to debug when maximum detail is needed for troubleshooting, for example on a problematic example.local server block.
    error_log /var/log/nginx/example.local.error.log debug;
    The debug level only works if the Nginx binary was compiled with the --with-debug option. Check first with nginx -V before relying on this level, and do not forget to restore it to warn or error when finished, because the debug level generates a very high volume of logs and quickly fills up disk space on production servers.
  2. Ensure log rotation is already running automatically, because without rotation, access.log and error.log will grow indefinitely.
    cat /etc/logrotate.d/nginx

Verification and Troubleshooting

  • The nginx package from the Ubuntu repository includes this logrotate configuration by default, generally running daily and keeping several days of history before old files are deleted or compressed. A deeper discussion on log retention strategies across multiple servers will be covered in Chapter 39.
  • If server disk space suddenly runs full and suspicion points to Nginx logs, check their size first before blaming traffic volume.
    sudo du -sh /var/log/nginx/*

Up to this point, Nginx is already running as a web server that actively answers the example.local and blog.example.local domains, complete with static content, basic reverse proxying to a backend, as well as access logs and error logs ready to be read whenever needed. Chapter 13 will discuss Apache as an alternative web server still widely used across various infrastructures, while comparing it with Nginx so we have a clear basis to choose one according to our needs.