Harden a Secure Nginx Web Server

Configure Nginx with TLS, cipher hardening, and security headers.

Introduction

30 Second Summary

Every time you visit a website, your browser silently checks whether the connection is encrypted and trustworthy. The web server on the other side is responsible for proving its identity and protecting data in transit.

In this project, you will harden an Nginx web server running on your local Ubuntu VM. You'll generate a TLS certificate, enforce modern encryption protocols, add browser security headers, and learn to read server logs.

What You'll Build

You can point curl at your server and see proof of every hardening layer in the response: an encrypted HTTPS connection, five security headers active, and no server version leaked.

By the end of this project, you'll have:

  • A live HTTPS server with a self-signed TLS certificate you generated yourself, where you can verify the certificate details using command-line tools.
  • A hardened TLS configuration that rejects outdated protocols and enforces modern cipher suites, proven by testing with openssl s_client.
  • Security headers visible in every response that protect against clickjacking, MIME-sniffing, and protocol downgrades. Plus the ability to read access logs and identify exactly who visited your site, when, and what they requested.
  • Secret Mission: Add rate limiting to defend against brute-force attacks.

Are there any prerequisites?

You need a Mac with Ubuntu running via Parallels (or another virtualisation tool). Basic Linux command line familiarity (cd, ls, cat) is helpful, but all text editing uses nano with step-by-step save and exit instructions included.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Web Server

Before you can secure a web server, you need a web server to secure. In this step, you will install Nginx and get a basic website running over plain HTTP.

Once the server is up, you will confirm that traffic is flowing unencrypted. That unencrypted connection is the problem you will solve across the rest of this project.

In this step, get ready to:
  • Install Nginx and confirm it is running.
  • Create a custom HTML page for your site.
  • Verify your site is reachable over HTTP.
Install Nginx and OpenSSL
  • Update your package list and install Nginx by running this command:
sudo apt update && sudo apt install nginx -y

What does this command do?

This is actually two commands joined by &&.

  • sudo apt update refreshes Ubuntu's list of available packages so you get the latest versions.
  • sudo apt install nginx -y installs Nginx and the -y flag automatically answers "yes" to confirmation prompts.
  • Confirm Nginx is running by checking its status:
sudo systemctl status nginx

Look for active (running) in the output. This confirms Nginx started successfully after installation.

Exit the status view

The status output takes over your terminal. Press q to exit and return to the command prompt.

Not seeing active (running)?

  • If you see "inactive (dead)", start Nginx manually with sudo systemctl start nginx, then check the status again.
  • If apt failed to install, make sure your VM has internet access and try sudo apt update again.

Need more help? Help me troubleshoot Nginx not starting on my Ubuntu VM.

  • Verify OpenSSL is available by checking its version:
openssl version

You should see OpenSSL 3.x in the output. OpenSSL is pre-installed on Ubuntu 24.04 and you will use it later to generate your TLS certificate.

Create a basic website

Nginx serves files from /var/www/html/ by default. You will replace the default welcome page with your own HTML file.

  • Open the default web page in the nano text editor by running this command:
sudo nano /var/www/html/index.html
  • Delete all existing content by pressing Ctrl+K repeatedly until the file is empty.
  • Type or paste the following HTML into the empty file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Secure Server</title>
</head>
<body>
    <h1>Welcome to My Secure Server</h1>
    <p>This site is served over HTTPS with a hardened TLS configuration.</p>
    <p>Security headers protect against clickjacking, MIME-sniffing, and protocol downgrades.</p>
</body>
</html>

What does this HTML do?

This is a simple static web page that your server will deliver to anyone who visits. The content describes the security features you will add throughout this project.

Right now the page talks about HTTPS and security headers, but you have not configured either yet. By the end of the project, every claim on this page will be true.

  • Save the file by pressing Ctrl+O, then press Enter to confirm the filename.
  • Exit nano by pressing Ctrl+X.

✔️ Awesome, I've got everything!

Great. Make sure you have exited nano and are back at the terminal prompt before continuing.

ⓧ I'd like to double check the full code

Your /var/www/html/index.html should contain exactly this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Secure Server</title>
</head>
<body>
    <h1>Welcome to My Secure Server</h1>
    <p>This site is served over HTTPS with a hardened TLS configuration.</p>
    <p>Security headers protect against clickjacking, MIME-sniffing, and protocol downgrades.</p>
</body>
</html>
Verify HTTP is working

Your server is installed and your custom page is in place. Time to confirm that everything is wired together.

Before you run the next command, what do you expect to see? Will it show the Nginx default page or your custom HTML?

  • Test your web server by requesting the page with curl:
curl http://localhost

You should see the full HTML content of your custom page, including Welcome to My Secure Server.

Why does this matter?

This request uses plain HTTP. The content travels across the network as readable text with no encryption. Anyone between you and the server could read or modify it.

In the next step, you will generate a TLS certificate and switch this connection to HTTPS so that traffic is encrypted end-to-end.

Seeing "Connection refused"?

  • Run sudo systemctl start nginx to start the server if it stopped.
  • Run sudo systemctl status nginx to check for errors in the service status.
  • Make sure you saved the HTML file correctly. You can verify with cat /var/www/html/index.html.

Still stuck? Help me debug why curl http://localhost is not returning my HTML page.

Your web server is live and serving your page over HTTP. Next up, you will generate a TLS certificate and configure Nginx to encrypt all traffic with HTTPS.

Enable HTTPS with a Self-Signed Certificate

In the previous step, you set up a working web server and confirmed it returns your custom page over plain HTTP.

The problem is that HTTP traffic is completely unencrypted. Anyone on the same network can intercept and read it. To fix this, you need to generate a TLS certificate and configure Nginx to serve your site over HTTPS.

In this step, get ready to:
  • Generate a self-signed TLS certificate and private key with OpenSSL.
  • Configure Nginx to serve your site over HTTPS and redirect HTTP traffic.
  • Verify that your site loads over an encrypted connection.
Generate a self-signed TLS certificate with OpenSSL

A TLS certificate is what proves your server's identity and enables encryption. Since your VM is local and not on the public internet, you will create a self-signed certificate.

  • Create a directory to store your SSL files by running this command:
sudo mkdir -p /etc/nginx/ssl
  • Confirm the directory was created by running:
ls /etc/nginx/ssl

The command should run without errors. The directory is empty for now, which is expected.

  • Generate a self-signed certificate and private key in one command by running:
sudo openssl req -x509 -nodes -newkey rsa:2048 -keyout /etc/nginx/ssl/selfsigned.key -out /etc/nginx/ssl/selfsigned.crt -days 365 -subj "/CN=localhost" -addext "subjectAltName = DNS:localhost, IP:127.0.0.1"

What does this command do?

  • -x509 tells OpenSSL to output a signed certificate rather than a certificate signing request.
  • -nodes means no passphrase on the private key, so Nginx can read it without prompting.
  • -newkey rsa:2048 generates a fresh 2048-bit RSA key pair.
  • -days 365 sets the certificate to be valid for one year.
  • -subj "/CN=localhost" sets the subject without interactive prompts.
  • -addext "subjectAltName = DNS:localhost, IP:127.0.0.1" adds a Subject Alternative Name so browsers and tools accept the certificate for localhost.
  • Verify the certificate and key were created by running:
sudo ls -la /etc/nginx/ssl/

You should see selfsigned.crt and selfsigned.key listed in the output.

  • Inspect the certificate details by running:
openssl x509 -in /etc/nginx/ssl/selfsigned.crt -text -noout | head -20

You should see the Issuer and Subject both showing CN = localhost, a Validity period of 365 days, and a 2048-bit RSA Public Key.

Getting a permission denied error?

Make sure you used sudo for the openssl req command. The /etc/nginx/ssl/ directory is owned by root.

Help me fix this

Configure Nginx for HTTPS

Now that your certificate exists, you need to tell Nginx to use it. You will replace the default site configuration with one that redirects HTTP to HTTPS and serves content over port 443.

  • Open the default site configuration in nano by running:
sudo nano /etc/nginx/sites-available/default
  • Select all existing content and delete it. In nano, press Ctrl+K repeatedly until the file is empty.
  • Paste the following HTTPS configuration into the file:
# HTTP server - redirect all traffic to HTTPS
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name localhost;

    return 301 https://$host$request_uri;
}

# HTTPS server - serve content with TLS encryption
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name localhost;

    # TLS certificate and private key
    ssl_certificate /etc/nginx/ssl/selfsigned.crt;
    ssl_certificate_key /etc/nginx/ssl/selfsigned.key;

    # Document root
    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    # Logging
    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;
}

What does this configuration do?

The first server block listens on port 80 (plain HTTP) and immediately redirects every request to HTTPS with a 301 status code.

The second server block listens on port 443 with ssl enabled. The ssl_certificate and ssl_certificate_key directives point to the files you just generated.

  • Save the file by pressing Ctrl+O, then Enter.
  • Exit nano by pressing Ctrl+X.
  • Test the configuration syntax by running:
sudo nginx -t

You should see output containing "syntax is ok" and "test is successful".

  • Reload Nginx to apply the new configuration by running:
sudo systemctl reload nginx

Seeing a syntax error from nginx -t?

Double check that the ssl_certificate paths match exactly: /etc/nginx/ssl/selfsigned.crt and /etc/nginx/ssl/selfsigned.key. A typo in the file path is the most common cause.

Also make sure every opening brace { has a matching closing brace }.

Help me debug my Nginx config

Test HTTPS

Your server is now configured to serve traffic over HTTPS. Before you test, think about what you expect to happen when you request your page over an encrypted connection.

What do you think curl will show when you request https://localhost? Will it succeed normally, or will it complain about something?

  • Test your HTTPS connection by running:
curl -k https://localhost

You should see the HTML content of your page returned. The -k flag tells curl to skip certificate trust validation, which is necessary because your certificate is self-signed and not issued by a trusted Certificate Authority.

  • Verify that HTTP requests are redirected to HTTPS by running:
curl -I http://localhost

You should see HTTP/1.1 301 Moved Permanently with a Location: https://localhost/ header. This confirms that plain HTTP traffic is being forced to HTTPS.

  • Inspect the HTTPS response headers by running:
curl -kI https://localhost

Look at the output carefully. The Server header reveals the Nginx version number, and there are no security headers present at all. These are the gaps you will close in the next steps.

Seeing connection refused on port 443?

Run sudo systemctl status nginx and check that the service shows "active (running)". If it failed to start, the certificate paths in your config likely don't match the actual file locations.

Help me troubleshoot this

✔️ Awesome, I've got everything!

Great. Double check you have saved /etc/nginx/sites-available/default and that curl -k https://localhost returns your page.

ⓧ I'd like to double check the full code

Here is the complete Nginx configuration file for this step:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name localhost;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name localhost;

    ssl_certificate /etc/nginx/ssl/selfsigned.crt;
    ssl_certificate_key /etc/nginx/ssl/selfsigned.key;

    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;
}

Your site is now encrypted. Next up, you will harden the TLS configuration to enforce modern protocols and strong cipher suites that meet current best practices.

Harden TLS Protocols and Ciphers

Your HTTPS connection is working, but the default Nginx configuration does not explicitly restrict which TLS versions or ciphers clients can negotiate. This means older, deprecated protocols like TLS 1.0 could still be accepted.

In this step, you will lock down the allowed protocols to TLS 1.2 and 1.3 only, configure a strong set of cipher suites from the Mozilla Intermediate profile, and generate Diffie-Hellman parameters for forward secrecy. You will also hide the Nginx version number that currently leaks in response headers.

In this step, get ready to:
  • Generate Diffie-Hellman parameters for forward secrecy.
  • Add TLS hardening directives to the Nginx configuration.
  • Verify that deprecated protocols are rejected and modern ciphers are active.
Generate Diffie-Hellman parameters

Forward secrecy means that even if your server's private key is compromised in the future, past encrypted sessions remain safe. Diffie-Hellman (DH) parameters enable the DHE key exchange that provides this protection for clients that cannot use ECDHE.

  • Generate a 2048-bit DH parameter file by running this command:
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

What does this command do?

This generates a set of mathematical parameters that Nginx uses for DHE key exchange. The 2048-bit size is considered secure through at least 2030 by NIST guidelines.

The command takes 10-30 seconds to complete because it must find large prime numbers. Wait for it to finish before continuing.

  • Verify the file was created by running:
sudo ls -la /etc/nginx/ssl/

You should see dhparam.pem listed alongside your selfsigned.crt and selfsigned.key files.

Don't see dhparam.pem?

Make sure the /etc/nginx/ssl/ directory exists. It was created in the previous step. If the command failed, check that you included sudo since writing to /etc/nginx/ requires root permissions.

Need help? Why can't I generate my DH parameters file?

Add TLS hardening directives to the Nginx config

With the DH parameters ready, you can now configure Nginx to enforce modern protocols and ciphers. These directives replace Nginx's permissive defaults with explicit rules about what connections are allowed.

  • Open the site configuration file by running:
sudo nano /etc/nginx/sites-available/default
  • Find the ssl_certificate_key line inside the HTTPS server block:
    ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
  • Add a blank line after ssl_certificate_key, then paste the following TLS hardening directives:
    # TLS Hardening (Mozilla Intermediate profile)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_dhparam /etc/nginx/ssl/dhparam.pem;

    # Session settings
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    # Hide server version
    server_tokens off;

What do these directives do?

  • ssl_protocols TLSv1.2 TLSv1.3 disables TLS 1.0 and 1.1, which are formally deprecated by RFC 8996.
  • ssl_ciphers specifies only AEAD cipher suites with ECDHE or DHE key exchange. Every cipher in this list provides forward secrecy.
  • ssl_prefer_server_ciphers off lets TLS 1.3 clients choose the optimal cipher for their hardware. All ciphers in the list are equally strong, so client preference is safe.
  • ssl_dhparam points to the DH parameters file you just generated for DHE key exchange.
  • ssl_session_tickets off disables session tickets to prevent forward secrecy bypass from unrotated ticket keys.
  • server_tokens off hides the Nginx version number from the Server response header and error pages.
  • Save the file: press Ctrl+O, then Enter.
  • Exit nano: press Ctrl+X.
  • Test the configuration syntax by running:
sudo nginx -t

You should see "syntax is ok" and "test is successful" in the output.

  • Reload Nginx to apply the changes by running:
sudo systemctl reload nginx

Seeing a syntax error?

Open the file again with sudo nano /etc/nginx/sites-available/default and check for missing semicolons at the end of each directive. Every line except comments and blank lines must end with a semicolon.

Also confirm the new directives are inside the server block that listens on port 443, not inside the port 80 redirect block.

Help me fix my Nginx config syntax error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file and that sudo nginx -t passes before continuing.

ⓧ I'd like to double check the full code

Your /etc/nginx/sites-available/default file is now longer than 30 lines. Scroll through and confirm these sections are present, in order:

  • The HTTP redirect block on port 80 with return 301 https://$host$request_uri;
  • The HTTPS server block on port 443 with ssl_certificate and ssl_certificate_key lines.
  • The TLS hardening block: ssl_protocols, ssl_ciphers, ssl_prefer_server_ciphers, ssl_dhparam.
  • The session settings: ssl_session_timeout, ssl_session_cache, ssl_session_tickets.
  • server_tokens off; on its own line.
  • The document root, location block, and logging directives unchanged from before.
Test and verify the hardened TLS

The configuration is loaded. Now you will prove that the hardening actually works by testing with openssl s_client.

Before you run the next command, what do you expect to see for the protocol version? Will it show TLS 1.2, or something else?

  • Verify TLS 1.2 works by running:
openssl s_client -connect localhost:443 -tls1_2 < /dev/null 2>/dev/null | grep -E "Protocol|Cipher"

You should see Protocol : TLSv1.2 and a cipher name starting with ECDHE or DHE. This confirms your server negotiated a forward-secret cipher.

  • Verify TLS 1.3 also works by running:
openssl s_client -connect localhost:443 -tls1_3 < /dev/null 2>/dev/null | grep "Protocol"

You should see Protocol : TLSv1.3 in the output.

  • Verify the server version is now hidden by running:
curl -kI https://localhost | grep Server

You should see Server: nginx without any version number after it. Compare this to Step 2 where the full version was exposed.

Why does hiding the version matter?

Attackers scan for known vulnerabilities tied to specific software versions. By removing the version from response headers, you eliminate one piece of reconnaissance data. This is called reducing the attack surface.

Still seeing a version number?

Make sure server_tokens off; is inside the port 443 server block, not outside it or in the port 80 block. Also confirm you reloaded Nginx after saving the config.

Help me hide my Nginx version number.

Your server now rejects deprecated protocols, negotiates only forward-secret ciphers, and hides its version from attackers. Next up, you will add security headers that instruct browsers to defend against client-side attacks like clickjacking and MIME-sniffing.

Add Security Headers

Your server now encrypts every connection with hardened TLS. But encryption alone does not protect against client-side attacks.

Browsers need explicit instructions to prevent clickjacking, MIME-sniffing, and protocol downgrade attacks. In this step, you'll add security headers that tell browsers exactly how to handle your site's content and connections.

In this step, get ready to:
  • Add five security headers to the Nginx HTTPS server block.
  • Verify all headers appear in HTTPS responses using curl.
Add security headers to the Nginx config

Without security headers, a browser has no way to know it should refuse to embed your site in an iframe, reject mismatched MIME types, or remember to always use HTTPS. Each header you add solves a specific class of attack.

  • Open the site configuration file by running:
sudo nano /etc/nginx/sites-available/default
  • Inside the HTTPS server block (the one that starts with listen 443 ssl), find the line that says:
    server_tokens off;
  • Add a blank line below server_tokens off; and then paste the following security headers:
    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header X-XSS-Protection "0" always;

What does each header do?

  • Strict-Transport-Security tells browsers to only connect over HTTPS for the next 2 years (63072000 seconds). This prevents protocol downgrade attacks.
  • X-Frame-Options set to DENY blocks other sites from embedding your page in an iframe. This prevents clickjacking.
  • X-Content-Type-Options set to nosniff stops browsers from guessing file types. This prevents MIME-sniffing attacks where a browser treats a file as executable code.
  • Referrer-Policy controls how much URL information is shared when a visitor clicks a link to leave your site.
  • X-XSS-Protection set to 0 explicitly disables the broken legacy XSS filter in old browsers. Modern browsers have built-in protections that supersede it.

Why does every header end with always?

By default, add_header only attaches headers to successful responses (200, 301, etc.). The always keyword tells Nginx to include the header on every response, including 404 and 500 errors. Without it, an attacker could trigger an error page that lacks your security protections.

  • Save the file: press Ctrl+O, then Enter.
  • Exit nano: press Ctrl+X.
  • Test the configuration syntax and reload Nginx by running:
sudo nginx -t && sudo systemctl reload nginx

You should see "syntax is ok" and "test is successful" before the reload completes.

Seeing a syntax error?

  • Check that every add_header line ends with always; (semicolon after always, not before).
  • Make sure the quoted values have matching double quotes on both sides.
  • Confirm the headers are inside the server { } block that listens on port 443, not outside it.

Help me fix a syntax error in my Nginx security headers configuration.

Verify all security headers

Before you run the check, think about what you expect to see. Do you think the response headers will look different from when you ran curl -kI https://localhost at the end of the last step?

  • Check the response headers by running:
curl -kI https://localhost

You should see all five security headers in the output, along with Server: nginx without a version number.

What should I see?

Your response headers now include these lines (among others):

  • Strict-Transport-Security: max-age=63072000; includeSubDomains
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-XSS-Protection: 0

Compare this to the bare response you saw before. The response now includes 5 security headers that were completely absent before hardening.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that all five headers appear when you run curl -kI https://localhost.

ⓧ I'd like to double check the full code

Your /etc/nginx/sites-available/default file should contain these sections in order inside the HTTPS server block:

  • TLS certificate directives (ssl_certificate and ssl_certificate_key).
  • TLS hardening directives (ssl_protocols, ssl_ciphers, ssl_prefer_server_ciphers, ssl_dhparam).
  • Session settings (ssl_session_timeout, ssl_session_cache, ssl_session_tickets off).
  • server_tokens off; to hide the version.
  • Five add_header lines (Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, X-XSS-Protection), each ending with always;.
  • Document root (root /var/www/html; and index index.html;).
  • Location block with try_files.
  • Logging directives (access_log and error_log).

The HTTP server block (port 80) should still contain just the return 301 redirect to HTTPS.

Your server now sends browser-level security instructions on every response. Next up, you'll learn how to read and analyze the server logs that record every request hitting your hardened server.

Read and Analyze Server Logs

Your server now encrypts every connection and sends all the right security headers. But how would you know if someone is probing it for weaknesses right now?

Nginx logs record every single request that hits your server and every error it encounters. In this step, you will learn where those logs live, how to read them, and how to filter them to spot problems fast.

In this step, get ready to:
  • Generate log entries and examine the access log format.
  • Filter error logs to identify failed requests.
  • Monitor logs in real time with tail -f.
Generate log entries and examine access logs

To learn the log format, you need some diverse entries to look at. You will make three different requests that each leave a distinct footprint in the log.

  • Make a normal successful request by running:
curl -k https://localhost
  • Request a page that does not exist by running:
curl -k https://localhost/nonexistent-page

You should see a 404 Not Found HTML page returned by Nginx.

  • Send a request with a custom User-Agent header by running:
curl -k https://localhost -H "User-Agent: SecurityTest"

Why three different requests?

  • The first request is a normal successful page load (status 200).
  • The second requests a page that does not exist, generating a 404 error.
  • The third sends a custom User-Agent header, which the log records. In production, unusual user agents can signal automated scanners probing your server.
  • View the last 10 lines of the access log by running:
sudo tail -10 /var/log/nginx/access.log

You should see entries from the three requests you just made, plus any earlier requests from previous steps.

What does each field in the access log mean?

Nginx uses the combined log format by default. Each line contains these fields in order:

  • Remote address (127.0.0.1): the IP of the client that made the request.
  • Remote user: usually a dash (-) unless HTTP authentication is used.
  • Timestamp: the date and time of the request in square brackets.
  • Request line: the HTTP method, path, and protocol (e.g. GET /nonexistent-page HTTP/1.1).
  • Status code: 200 for success, 404 for not found.
  • Body bytes sent: how many bytes the response body contained.
  • Referrer: the page that linked to this request (a dash if direct).
  • User-Agent: the client software that made the request. Look for your SecurityTest entry.
  • Find the 404 entry in your output. It should show GET /nonexistent-page with a status code of 404.
  • Find the custom user agent entry. It should show SecurityTest at the end of the line instead of the default curl user agent.

Not seeing your recent requests?

  • Make sure you used sudo before tail. The log files are owned by root and require elevated permissions to read.
  • If the log has many entries from previous testing, try sudo tail -20 to see more lines.

Help me read my Nginx access log entries.

Examine error logs

The access log records every request regardless of outcome. The error log specifically captures problems that Nginx encountered while processing requests.

  • View the error log by running:
sudo cat /var/log/nginx/error.log

You should see at least one entry from when Nginx tried to serve /nonexistent-page and could not find the file on disk.

What do error log entries contain?

Each error log entry shows a timestamp, the severity level (such as error, warn, or notice), the process ID, and the actual error message.

For the 404 request, you will see a message about a failed file open followed by the file path Nginx tried to access. This tells you exactly what the server was looking for when it returned the 404.

  • Filter the access log to find all 404 responses by running:
sudo grep "404" /var/log/nginx/access.log

You should see only the lines where the status code is 404.

  • Filter the error log to show only error-level messages by running:
sudo grep "error" /var/log/nginx/error.log

This returns all entries containing the word "error". In production, you would use grep like this to quickly search through large log files for specific problems.

Error log is empty?

If you see no output, make sure you ran the curl -k https://localhost/nonexistent-page command earlier. That 404 request generates the error entry.

Help me understand why my Nginx error log is empty.

Understand log rotation and real-time monitoring

Log files grow continuously. Without management, they would eventually fill your disk.

  • Check how log rotation is configured by running:
cat /etc/logrotate.d/nginx

You should see a configuration that specifies daily rotation and keeps 14 days of logs. This means old logs are automatically compressed and eventually deleted so your disk never fills up.

Why does log rotation matter?

A busy production server can generate gigabytes of logs per day. Log rotation automatically archives yesterday's log into a compressed file and starts a fresh one.

The configuration also includes a postrotate command that signals Nginx to reopen its log files after rotation completes.

Now try monitoring logs in real time. This is how you would watch for suspicious activity on a live server.

  • Start real-time log monitoring by running:
sudo tail -f /var/log/nginx/access.log

Your terminal now waits for new entries and prints them as they arrive. The cursor will sit and wait.

  • Open a second terminal in your Ubuntu VM.
  • In the second terminal, generate a request by running:
curl -k https://localhost
  • Switch back to your first terminal. You should see a new log entry appear instantly.
  • Press Ctrl+C in the first terminal to stop the live monitoring.

When would I use tail -f in production?

Real-time monitoring is essential when debugging a live issue. If users report errors, you can watch the access and error logs as requests arrive to catch the problem the moment it happens.

Before you run the final verification, predict: what fields do you think you will see in each access log line?

  • Confirm you can identify all fields in the most recent log entries by running:
sudo tail -5 /var/log/nginx/access.log

You should be able to point to the IP address, timestamp, HTTP method, path, status code, and user agent in each line.

Still unsure which field is which?

Count the fields separated by spaces and quotes. The pattern is: IP, dash, dash, [timestamp], "request line", status, bytes, "referrer", "user agent".

Help me identify the fields in my Nginx combined log format.

Your server is now fully hardened and you know how to read every request and error it records. Next up, the Secret Mission challenges you to add rate limiting to defend against brute-force attacks.

Secret mission

Add Rate Limiting

Your server is locked down against protocol and header attacks. But what happens if a single client hammers your server with thousands of requests per second? In this secret mission, you'll configure Nginx rate limiting to defend against brute-force and denial-of-service attacks.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local Ubuntu VM, so there are no ongoing cloud costs.

Resources you used:

  • Nginx web server (installed via apt).
  • Self-signed TLS certificate and private key in /etc/nginx/ssl/.
  • Diffie-Hellman parameters file in /etc/nginx/ssl/dhparam.pem.
  • Custom HTML page at /var/www/html/index.html.
  • Modified Nginx configuration at /etc/nginx/sites-available/default and /etc/nginx/nginx.conf.

Keep everything running

No action needed. Choose this if you are still actively building on your hardened server or plan to add more security features soon.

  • Your Nginx server continues serving HTTPS on port 443.
  • All TLS hardening, security headers, and rate limiting remain active.
  • Logs continue accumulating in /var/log/nginx/. Log rotation handles cleanup automatically.

Pause - I'll come back to this later

Stop the Nginx service to free up memory and ports, but keep all files in place so you can restart later.

  • Stop Nginx by running:
sudo systemctl stop nginx
  • Disable Nginx from starting on boot by running:
sudo systemctl disable nginx
  • When you are ready to resume, start it again by running:
sudo systemctl enable nginx && sudo systemctl start nginx

All your configuration files, certificates, and HTML remain untouched.

Delete - I don't want to use this again

Remove all project resources completely. This removes Nginx, your certificates, configuration, and custom HTML.

  • Stop Nginx and remove the package along with its configuration files by running:
sudo systemctl stop nginx
sudo apt purge nginx -y
sudo apt autoremove -y
  • Remove the SSL directory containing your certificate, private key, and DH parameters by running:
sudo rm -rf /etc/nginx/ssl/
  • Remove the custom HTML page by running:
sudo rm -f /var/www/html/index.html
  • Remove any remaining Nginx configuration and log files by running:
sudo rm -rf /etc/nginx/
sudo rm -rf /var/log/nginx/
  • Confirm Nginx is fully removed by running:
which nginx

This should return no output, confirming Nginx is no longer installed.

Nice Work!

Nice Work!

You just built a production-grade hardened web server from scratch. Your Nginx server now enforces encrypted connections, rejects insecure protocols, and sends security headers that protect visitors from common web attacks.

You've learned how to:

  • Generate a self-signed TLS certificate with OpenSSL and configure Nginx to serve your site over HTTPS with a redirect from HTTP.
  • Harden TLS by enforcing TLS 1.2 and 1.3 only, configuring modern AEAD cipher suites with forward secrecy, generating custom Diffie-Hellman parameters, and adding security headers that protect against clickjacking, MIME-sniffing, and protocol downgrades.
  • Read, filter, and monitor Nginx access and error logs in real time to identify who visited your server, what they requested, and what went wrong.
  • Secret Mission: Implemented rate limiting to defend against brute-force and denial-of-service attacks.

Ready to quiz yourself?