Harden a Production Web Server

Deploy and harden an Nginx web server on Rocky Linux 9 with SELinux.

Introduction

30 Second Summary

Every server connected to the internet is under constant attack. The difference between a server that survives and one that gets compromised comes down to how it was set up on day one.

In this project, you will provision a Rocky Linux 9 virtual machine, deploy an Nginx web server, and lock down every layer of the system. This is the exact workflow a Linux administrator follows when spinning up a new production server.

What You'll Build

A production-ready web server on a Rocky Linux 9 VM that meets real-world security standards.

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

  • A Rocky Linux 9 VM running Nginx over HTTPS with a TLS certificate and security headers.
  • A complete security stack: SELinux in Enforcing mode, firewalld rules, and SSH locked down with key-only authentication.
  • Persistent journald logging with size limits and automated Nginx log rotation.
  • Secret Mission: Prometheus Node Exporter added for production monitoring, the next thing every production server needs.

Are there any prerequisites?

This project assumes RHCSA-level Linux knowledge with the command line, package management, and systemd basics. You need VMware Workstation Pro installed on your Windows PC.

Download Rocky Linux 9 and Create the VM in VMware

Every production server starts as a blank machine. Before you can harden anything, you need a server to harden.

Rocky Linux is a free, enterprise-grade Linux distribution that is binary-compatible with Red Hat Enterprise Linux. You will download it, create a VM in VMware Workstation Pro, and complete a fresh installation.

In this step, get ready to:
  • Download the Rocky Linux 9 Minimal ISO.
  • Create and configure a new VM in VMware Workstation Pro.
  • Complete the Rocky Linux 9 installation and verify network connectivity.
Download the Rocky Linux 9 Minimal ISO

Rocky Linux provides a Minimal ISO that includes only the essential packages needed for a server. This is exactly what you want for a production deployment.

  • Open your web browser and go to the Rocky Linux download page.
  • Select Rocky Linux 9 from the version selector.
  • Download the Minimal ISO (approximately 2.5 GB).
  • Save the ISO file to a convenient location on your PC, such as your Downloads folder.

Pro tip: use the direct download link

If the download page is slow or confusing, use this direct link to the Minimal ISO to start the download immediately.

What is Rocky Linux?

Rocky Linux is a community-driven, enterprise-grade Linux distribution. It is binary-compatible with Red Hat Enterprise Linux (RHEL), meaning software built for RHEL runs on Rocky Linux without modification.

Rocky Linux 9 ships with SELinux enabled by default and is supported until May 2032. It is the same OS running on production servers across the industry.

Create a new VM in VMware Workstation

Now that you have the ISO, you will create a virtual machine configured as a production server.

  • Press the Windows key to open your search bar.
  • Type VMware Workstation and press Enter to open it.
  • Click File in the menu bar.
  • Select New Virtual Machine.
  • Select Typical (recommended) and click Next.
  • Select Installer disc image file (iso) and click Browse.
  • Navigate to the Rocky Linux ISO you downloaded and select it.
  • Click Next.

VMware says Red Hat Enterprise Linux 9?

VMware may identify the ISO as "Red Hat Enterprise Linux 9 64-bit." This is correct. Rocky Linux is binary-compatible with RHEL 9, so VMware recognizes it as the same family.

  • Enter production-web-server as the virtual machine name.
  • Click Next.
  • Set the disk size to 20 GB.
  • Select Store virtual disk as a single file.
  • Click Next.
  • Click Customize Hardware before finishing.

In the Hardware settings window, adjust the following:

  • Set Memory to 2048 MB (2 GB).
  • Set Processors to 2.
  • Confirm the Network Adapter is set to NAT.
  • Click Close to save the hardware settings.

Why NAT for the network adapter?

NAT mode gives your VM internet access for downloading packages and updates. It also assigns a private IP address that your Windows host can reach directly for SSH connections and browser testing.

  • Click Finish to create the VM.
  • Click Power on this virtual machine to start it.
Install Rocky Linux 9 Minimal

The Rocky Linux installer boots inside your VM. This installation takes about 5 to 10 minutes.

  • Select Install Rocky Linux 9 from the boot menu and press Enter.
  • Choose your preferred language and click Continue.

You will see the Installation Summary screen. Several items need your attention before you can begin.

  • Click Installation Destination.
  • Select the VMware virtual disk (it should already be highlighted).
  • Click Done.
  • Click Network & Host Name.
  • Toggle the network adapter switch to ON.
  • Note the IP address that appears. You will need it throughout this project.
  • In the Host Name field at the bottom, enter production-web-server and click Apply.
  • Click Done.
  • Click Root Password.
  • Set a strong root password and click Done.
  • Click User Creation.
  • Enter sysadmin as the username.
  • Check Make this user administrator.
  • Set a password for this user and click Done.

What does Make this user administrator do?

Checking this box adds the sysadmin user to the wheel group. On RHEL-family systems, the wheel group has sudo access by default. This means your sysadmin user can run commands as root using sudo.

  • Click Begin Installation and wait for the process to complete.
  • Click Reboot System when the installation finishes.

After the VM reboots, you will see a text-based login prompt.

  • Log in as sysadmin with the password you set during installation.
  • Verify your network connection by running:
ping -c 3 google.com

You should see three successful replies with round-trip times displayed.

Not seeing any replies?

  • Go back to the VMware menu bar and check that the Network Adapter is set to NAT and is Connected.
  • In the VM, run ip link and confirm your network interface (usually ens160 or ens33) shows state UP.
  • Try restarting the network with sudo nmcli networking off && sudo nmcli networking on.

Help me troubleshoot network connectivity on my Rocky Linux VM.

  • Update all system packages by running:
sudo dnf update -y

This downloads and installs the latest security patches and package updates. It may take a few minutes.

Update command failing?

  • Make sure the ping test above succeeded first. dnf needs internet access to download packages.
  • If you see a mirror error, run sudo dnf clean all and then retry the update command.

Help me fix dnf update errors on Rocky Linux 9.

  • Note your VM's IP address by running:
ip addr show

Look for your network interface (usually named ens160 or ens33). The IP address is listed under the inet field and looks something like 192.168.x.x.

Write this IP address down. You will use it throughout the rest of this project to connect to your server from Windows.

Your Rocky Linux 9 VM is up and running with network access. Next up, you will configure SSH access from your Windows machine and lock down the server.

Configure SSH Access and Harden the Server

Your Rocky Linux 9 VM is up and running with a fresh install. Right now you are logged in at the VM console, which works but is clunky.

In production, administrators never sit at the console. They connect over SSH from their own workstation using cryptographic keys instead of passwords. In this step, you will set up that exact workflow and then lock the door behind you by hardening the SSH configuration.

In this step, get ready to:
  • Generate an SSH key pair on your Windows machine and connect to the VM without a password.
  • Harden the SSH configuration to block root login and password authentication.
  • Verify your sudo access through the wheel group.
Enable SSH and connect from Windows

SSH is pre-installed on Rocky Linux 9 Minimal. Your first task is to confirm it is running on the VM and then connect from your Windows machine using a key pair.

  • On your VM console, verify SSH is running by running this command:
sudo systemctl status sshd

You should see active (running) in the output.

Seeing inactive or dead?

If SSH is not running, enable and start it by running:

sudo systemctl enable --now sshd

help me start sshd on Rocky Linux 9

Now switch to your Windows machine. You will generate an SSH key pair and use it to connect to the VM.

  • Press the Windows key to open your search bar.
  • Type Windows Terminal and press Enter to open it.
  • Generate an SSH key pair by running this command:
ssh-keygen -t rsa -b 4096 -f $HOME\.ssh\production-server-key
  • Press Enter twice to skip the passphrase prompts (or set a passphrase for extra security).

What does this command do?

  • -t rsa -b 4096 creates a 4096-bit RSA key pair. This is the standard strength for production SSH keys.
  • -f $HOME\.ssh\production-server-key saves the key pair to your Windows user's .ssh folder with a descriptive name.
  • Two files are created: production-server-key (your private key, never share this) and production-server-key.pub (your public key, goes on the server).
  • Copy the public key to your VM by running this command:
type $HOME\.ssh\production-server-key.pub | ssh sysadmin@[[VM_IP="your VM's IP address (e.g. 192.168.x.x)"]] "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
  • Enter the sysadmin password when prompted.

What does this command do?

This is the Windows equivalent of ssh-copy-id (which is not available natively on Windows). It reads your public key file and pipes it over SSH into the authorized_keys file on the server.

  • mkdir -p ~/.ssh creates the SSH directory if it does not already exist.
  • chmod 700 and chmod 600 set the strict permissions SSH requires on the directory and key file.
  • Test key-based login by running this command:
ssh -i $HOME\.ssh\production-server-key sysadmin@[[VM_IP="your VM's IP address (e.g. 192.168.x.x)"]]

You should be logged into your VM without being asked for a password. This confirms key-based authentication is working.

Still being asked for a password?

  • Make sure you used the correct VM IP address. Check it on the VM with ip addr show.
  • Verify the key was copied correctly by running cat ~/.ssh/authorized_keys on the VM. You should see your key content.
  • Check the permissions on the VM: ~/.ssh must be 700 and ~/.ssh/authorized_keys must be 600. SSH refuses keys if permissions are too open.

help me debug SSH key authentication

Harden the SSH configuration

Key-based login works. Now you will lock down SSH so that root cannot log in remotely and password authentication is completely disabled. This is the minimum hardening every production server needs.

  • On the VM (via your SSH session), create a hardening configuration file by running:
sudo vi /etc/ssh/sshd_config.d/10-hardening.conf
  • Press i to enter insert mode, then add the following content:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
  • Save and exit by pressing Esc, then typing :wq and pressing Enter.

What does this configuration do?

  • PermitRootLogin no blocks anyone from logging in as root over SSH. Attackers always try root first.
  • PasswordAuthentication no forces key-based login only. Passwords can be brute-forced. Keys cannot.
  • MaxAuthTries 3 disconnects after 3 failed attempts, slowing down any remaining attack surface.
  • ClientAliveInterval 300 and ClientAliveCountMax 2 disconnect idle sessions after 10 minutes of inactivity.
  • X11Forwarding no disables graphical forwarding. A production server has no GUI.

Why a drop-in file instead of editing sshd_config directly?

Files in /etc/ssh/sshd_config.d/ are processed alphabetically and override the main config. This keeps your customizations separate from the system defaults. If a package update replaces sshd_config, your hardening settings survive.

  • Validate the configuration has no syntax errors by running:
sudo sshd -t

No output means the configuration is valid. If you see an error, check for typos in the file.

  • Restart SSH to apply the new settings:
sudo systemctl restart sshd

Do not close your current SSH session yet

If there is a mistake in your configuration, closing your only session could lock you out of the server. Always test from a second terminal first.

  • Open a new Windows Terminal window (keep the existing session open).
  • Verify you can still log in with your key by running:
ssh -i $HOME\.ssh\production-server-key sysadmin@[[VM_IP="your VM's IP address (e.g. 192.168.x.x)"]]

You should be logged in successfully. Now test that your hardening is working.

  • Test that root login is blocked by running:
ssh -i $HOME\.ssh\production-server-key root@[[VM_IP="your VM's IP address (e.g. 192.168.x.x)"]]

You should see Permission denied in the output. Root cannot log in remotely.

  • Test that password authentication is blocked by running:
ssh -o PubkeyAuthentication=no sysadmin@[[VM_IP="your VM's IP address (e.g. 192.168.x.x)"]]

You should see Permission denied again. Without a key, nobody gets in.

Root login or password auth still working?

  • Confirm you saved the file to /etc/ssh/sshd_config.d/10-hardening.conf (not a different path).
  • Confirm you restarted SSH with sudo systemctl restart sshd. A reload is not enough for SSH changes.
  • Run sudo sshd -T | grep -i permitrootlogin to see the effective value. If it still shows yes, another config file may be overriding yours.

help me debug SSH hardening on Rocky Linux 9

Verify sudo access

You disabled root login over SSH. The only way to run administrative commands is through sudo as your sysadmin user. Confirm this path works correctly.

  • Confirm your user is in the wheel group by running:
groups

You should see sysadmin wheel in the output. The wheel group grants sudo access on RHEL-family systems.

  • Test that sudo actually works by running:
sudo whoami

The output should be root. This proves your user can escalate privileges when needed.

  • View the sudoers configuration to see how wheel group access is defined:
sudo cat /etc/sudoers | grep wheel

You should see %wheel ALL=(ALL) ALL in the output. This means any user in the wheel group can run any command as any user via sudo.

Why wheel and not a custom sudoers entry?

The wheel group is the standard sudo mechanism on RHEL-family systems. Rocky Linux pre-configures it in the default sudoers file. When you checked "Make this user administrator" during installation, the installer added your user to wheel automatically.

Your server is now locked down. Only your SSH key can get in, root is blocked remotely, and you have a verified sudo path for administrative tasks. Next up, you will deploy Nginx and configure the firewall to control what traffic reaches your server.

Deploy Nginx and Configure firewalld

Your server is locked down. SSH is hardened with key-only authentication, root login is disabled, and your sysadmin user has sudo access through the wheel group.

Now it is time to deploy the actual web server. You will install Nginx, bring it up as a systemd service, and configure firewalld to allow only the traffic your server needs.

In this step, get ready to:
  • Install and start Nginx as a systemd-managed web server.
  • Configure firewalld to allow HTTP and HTTPS traffic.
  • Create a custom web page to replace the default Nginx test page.
Install and start Nginx

Nginx is available in the default Rocky Linux 9 package repositories. No extra repos are needed.

  • Install Nginx by running this command:
sudo dnf install nginx -y

You should see Complete! at the end of the output.

Seeing a dnf error?

Make sure your VM has internet access. Run ping -c 3 google.com to check. If it fails, verify your VMware network adapter is set to NAT and the network interface is enabled inside the VM.

help me troubleshoot why dnf install nginx is failing on Rocky Linux 9

  • Enable Nginx to start on boot and start it immediately by running:
sudo systemctl enable --now nginx

What does --now do?

The enable part configures Nginx to start automatically on every boot. The --now flag also starts the service immediately, so you do not have to run a separate start command.

  • Verify Nginx is running by running:
sudo systemctl status nginx

You should see active (running) in the output.

  • Check the installed version by running:
nginx -v
  • Confirm Nginx is serving content by running:
curl http://localhost

You should see a large block of raw HTML. This is the default Rocky Linux Nginx test page.

Seeing Connection refused?

  • Run sudo systemctl status nginx again and check for error lines in the output.
  • If the status shows failed, check the logs with journalctl -u nginx.service --no-pager -n 20 for details on what went wrong.
  • Make sure nothing else is using port 80. Run sudo ss -tlnp | grep :80 to check.

help me troubleshoot why Nginx is not responding on localhost

Configure firewalld

Nginx is running, but nobody outside the VM can reach it yet. Rocky Linux 9 ships with firewalld active by default, and it only allows SSH traffic.

You need to open the firewall for HTTP (port 80) and HTTPS (port 443) while keeping everything else blocked.

  • Confirm firewalld is running:
sudo firewall-cmd --state

You should see running in the output.

  • View the current rules on the default zone by running:
sudo firewall-cmd --list-all

What am I looking at?

The output shows the active firewall zone (usually public) and the services currently allowed through. Right now, you should only see ssh in the services line. That means only SSH traffic is permitted.

  • Add HTTP and HTTPS services permanently by running these commands:
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https

Why the --permanent flag?

Without --permanent, firewalld rules only last until the next reload or reboot. The --permanent flag writes the rules to disk so they persist. Permanent rules do not take effect until you reload the firewall.

  • Reload the firewall to apply the new rules:
sudo firewall-cmd --reload
  • Verify the rules are active by running:
sudo firewall-cmd --list-all

You should now see ssh, http, and https in the services line.

Time to test from outside the VM.

  • On your Windows machine, open a web browser.
  • Navigate to http:// followed by your VM's IP address (the one you noted in Step 1).

You should see the Rocky Linux Nginx test page displayed in your browser. This confirms that Nginx is serving content and the firewall is allowing HTTP traffic through.

Page not loading in the browser?

  • Double-check your VM's IP address by running ip addr show inside the VM. Look for the IP on the ens* or eth* interface.
  • Make sure you typed http:// (not https://). HTTPS is not configured yet.
  • Confirm the firewall rules took effect by running sudo firewall-cmd --list-all again. If http is missing from the services, re-run the add and reload commands above.

help me troubleshoot why I cannot reach Nginx from my Windows browser

Create a custom web page

The default Nginx test page proves the server works, but a production server should serve your own content. You will replace the default page with a custom HTML page that identifies this as your hardened production server.

  • Open the default Nginx index page for editing by running:
sudo vi /usr/share/nginx/html/index.html
  • Press gg to move to the top of the file.
  • Type dG to delete everything in the file.
  • Press i to enter insert mode, then paste the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Production Server</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background-color: #1a1a2e;
            color: #e0e0e0;
        }
        .container {
            text-align: center;
            padding: 2rem;
        }
        h1 { color: #00d4aa; }
        p { color: #a0a0b0; }
        .status { color: #00d4aa; font-weight: bold; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Production Web Server</h1>
        <p>Rocky Linux 9 | Nginx | SELinux Enforcing</p>
        <p class="status">Status: Hardened and Running</p>
        <p>Deployed and secured by a Linux administrator.</p>
    </div>
</body>
</html>

What does this page do?

This is a simple status page that identifies the server as a production system. It shows the operating system, the web server, and the SELinux status. Dark background, centered layout, minimal design.

  • Press Esc to exit insert mode.
  • Type :wq and press Enter to save and quit.
  • Reload Nginx to pick up the new page by running:
sudo systemctl reload nginx
  • Go back to your Windows browser and refresh the page at your VM's IP address.

You should see your custom page with a dark background, a green "Production Web Server" heading, and the "Status: Hardened and Running" line.

Still seeing the old test page?

  • Hard-refresh your browser with Ctrl+Shift+R to bypass the cache.
  • Confirm the file was saved correctly by running cat /usr/share/nginx/html/index.html inside the VM. You should see your custom HTML.
  • Make sure you ran sudo systemctl reload nginx after saving the file. Nginx serves the version it loaded at startup until you reload it.

help me figure out why my custom Nginx page is not showing up

Your web server is live and your firewall is locked down to only three services. Next up, you will tackle the part that separates real Linux admins from tutorial followers: configuring SELinux in Enforcing mode.

Configure SELinux for Nginx in Enforcing Mode

Your Nginx server is deployed and firewalld is letting traffic through. The next security layer is SELinux, and this is where many Linux admins stumble.

Most online tutorials tell you to disable SELinux because it blocks things. You will not disable it. You will configure SELinux policies properly so Nginx can serve content from a custom directory while Enforcing mode stays on.

In this step, get ready to:
  • Verify that SELinux is in Enforcing mode and understand how it controls Nginx.
  • Create a custom web root and fix SELinux file contexts to resolve access denials.
  • Configure SELinux booleans to allow Nginx network access.
Verify SELinux status and understand the policy

SELinux assigns a security context to every file and process on the system. Nginx runs under the httpd_t context. The default policy only allows httpd_t to read files that carry a matching context like httpd_sys_content_t.

  • Confirm SELinux is in Enforcing mode by running this command:
sestatus

You should see SELinux status: enabled and Current mode: enforcing in the output.

  • View all SELinux booleans related to HTTP by running:
getsebool -a | grep httpd

What are SELinux booleans?

Booleans are on/off switches that control specific SELinux policy behaviors. For example, httpd_can_network_connect controls whether Nginx is allowed to make outgoing network connections. You will configure one of these booleans later in this step.

  • Verify the SELinux context on Nginx's default web root by running:
ls -Z /usr/share/nginx/html/

You should see the httpd_sys_content_t type on each file. This is why Nginx can serve files from this directory without any issues.

Why does the default directory work but a new one might not?

Rocky Linux ships with SELinux rules that pre-label standard web directories like /usr/share/nginx/html/ with the httpd_sys_content_t context. When you create a new directory outside these pre-labeled paths, the new files inherit a generic context that Nginx is not allowed to read. You are about to see this happen firsthand.

Create a custom web root and fix SELinux contexts

Production servers typically serve content from a dedicated directory rather than the Nginx default. You will create a new web root, point Nginx at it, and then watch SELinux block the request. Then you will fix it the right way.

  • Create the new web root directory by running this command:
sudo mkdir -p /var/www/production/html
  • Open a new file for your production index page by running:
sudo vi /var/www/production/html/index.html
  • Press i to enter insert mode, then paste the following HTML content:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Production Server</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background-color: #1a1a2e;
            color: #e0e0e0;
        }
        .container {
            text-align: center;
            padding: 2rem;
        }
        h1 { color: #00d4aa; }
        p { color: #a0a0b0; }
        .status { color: #00d4aa; font-weight: bold; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Production Web Server</h1>
        <p>Rocky Linux 9 | Nginx | SELinux Enforcing</p>
        <p class="status">Status: Hardened and Running</p>
        <p>Deployed and secured by a Linux administrator.</p>
    </div>
</body>
</html>

What does this page do?

This is a simple landing page that identifies the server as a production machine. It displays the server's technology stack and a status indicator. The dark color scheme keeps it clean and professional.

  • Save and exit vi by pressing Esc, then typing :wq and pressing Enter.
  • Confirm the file was created by running:
ls /var/www/production/html/

You should see index.html in the output.

Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Production Server</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background-color: #1a1a2e;
            color: #e0e0e0;
        }
        .container {
            text-align: center;
            padding: 2rem;
        }
        h1 { color: #00d4aa; }
        p { color: #a0a0b0; }
        .status { color: #00d4aa; font-weight: bold; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Production Web Server</h1>
        <p>Rocky Linux 9 | Nginx | SELinux Enforcing</p>
        <p class="status">Status: Hardened and Running</p>
        <p>Deployed and secured by a Linux administrator.</p>
    </div>
</body>
</html>

Now you need an Nginx server block that points to this new directory.

  • Create a new Nginx server block configuration file by running:
sudo vi /etc/nginx/conf.d/production.conf
  • Press i to enter insert mode, then paste the following configuration:
# Server block for the production site
server {
    listen 80;
    listen [::]:80;
    server_name _;

    # Point to the custom web root
    root /var/www/production/html;
    index index.html;

    # Serve static files, return 404 if not found
    location / {
        try_files $uri $uri/ =404;
    }
}

What does this configuration do?

  • listen 80 tells Nginx to accept HTTP connections on port 80 for both IPv4 and IPv6.
  • server_name _ is a catch-all that matches any hostname. This is useful for a server without a registered domain name.
  • root points Nginx at your new custom web root instead of the default directory.
  • try_files attempts to serve the requested file, then the directory, and returns a 404 if neither exists.
  • Save and exit vi by pressing Esc, then typing :wq and pressing Enter.

Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

I'd like to double check the full code

# Server block for the production site
server {
    listen 80;
    listen [::]:80;
    server_name _;

    # Point to the custom web root
    root /var/www/production/html;
    index index.html;

    # Serve static files, return 404 if not found
    location / {
        try_files $uri $uri/ =404;
    }
}
  • Test the Nginx configuration for syntax errors by running:
sudo nginx -t

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

Seeing a syntax error?

Open /etc/nginx/conf.d/production.conf again and check for missing semicolons at the end of each directive. Every line inside the server block except the opening { and closing } braces must end with a semicolon.

Help me debug my Nginx configuration syntax error

  • Reload Nginx to apply the new server block by running:
sudo systemctl reload nginx
  • Open a browser on your Windows machine and navigate to http:// followed by your VM's IP address.

The page will either fail to load or show the old default page. This is SELinux doing its job. Nginx is trying to read files from /var/www/production/html/, but SELinux has not authorized that directory for web content.

  • Check the SELinux audit log for the denial by running:
sudo ausearch -m avc -ts recent

What does the audit log show?

The ausearch command queries the SELinux audit log for Access Vector Cache (AVC) denials. You should see a denial entry showing that the httpd_t process (Nginx) was denied read access to a file in your new directory. The target context will be something generic like default_t instead of the required httpd_sys_content_t.

Now you know the problem. The fix is to permanently label the new directory with the correct SELinux file context.

  • Apply the correct SELinux file context to the new web root by running these two commands:
# Add a permanent file context rule for the new web root
sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/production/html(/.*)?"

# Apply the rule to the existing files on disk
sudo restorecon -Rv /var/www/production/html

What do these commands do?

  • semanage fcontext -a adds a new rule to the SELinux policy database. The rule says "every file under /var/www/production/html/ should carry the httpd_sys_content_t type." This rule is permanent and survives reboots.
  • restorecon -Rv reads the policy database and applies the correct labels to the files that already exist on disk. The -R flag is recursive and -v shows you each file it relabels.

Why semanage instead of chcon?

The chcon command also changes SELinux contexts, but the change is temporary. Running restorecon or relabeling the filesystem would wipe it out. The semanage fcontext approach writes a permanent rule to the policy database, which is the correct method for production servers.

  • Verify the context was applied correctly by running:
ls -Z /var/www/production/html/

You should see httpd_sys_content_t on index.html.

  • Refresh your browser. You should see your production page with a dark background, green heading text, and the status line "Hardened and Running."

Still seeing the old page or an error?

  • Make sure you saved production.conf and ran sudo systemctl reload nginx after creating the file.
  • Check that the root path in production.conf is exactly /var/www/production/html with no trailing slash.
  • Run sudo ausearch -m avc -ts recent again to see if there are any new SELinux denials.

Help me troubleshoot SELinux denials for my Nginx custom web root

Configure SELinux booleans for network access

SELinux booleans control specific behaviors beyond file access. One important boolean is httpd_can_network_connect, which controls whether Nginx is allowed to make outgoing network connections. This is required when Nginx acts as a reverse proxy.

  • Check the current state of the network boolean by running:
getsebool httpd_can_network_connect

The output should show httpd_can_network_connect --> off. Nginx is currently blocked from making outgoing connections.

  • Enable the boolean persistently by running:
sudo setsebool -P httpd_can_network_connect on

What does the -P flag do?

The -P flag makes the change persistent across reboots. Without it, the boolean would revert to its default value after a restart. This command may take a few seconds to complete because it writes to the SELinux policy store.

  • Verify the boolean is now enabled by running:
getsebool httpd_can_network_connect

The output should now show httpd_can_network_connect --> on.

  • Review two other useful SELinux booleans by running:
getsebool httpd_can_network_connect_db
getsebool httpd_can_sendmail

When would you enable these booleans?

  • httpd_can_network_connect_db allows Nginx to connect to a database server. You would enable this if your web application uses a database backend.
  • httpd_can_sendmail allows Nginx to send email. You would enable this if your web application sends notifications.

In production, you only enable the booleans you actually need. The principle of least privilege applies to SELinux the same way it applies to user permissions and firewall rules.

Getting a permission error on setsebool?

Make sure you are running the command with sudo. Only root can change SELinux booleans. If you are logged in as sysadmin with wheel group membership, sudo will work.

Help me fix a permission error when setting SELinux booleans

SELinux is now fully configured for Nginx. Your server has Enforcing mode on, proper file contexts for the custom web root, and the network boolean enabled for future reverse proxy use. Next up, you will generate a TLS certificate and configure HTTPS to encrypt all traffic to your server.

Generate a TLS Certificate and Harden Nginx

SELinux is locked down and protecting your web server. Your file contexts are set, and the right booleans are enabled.

There is one major gap left. All traffic between your browser and the server travels in plain text, which means anyone on the network can read it. In this step, you'll generate a TLS certificate and configure Nginx to serve everything over HTTPS with security headers.

In this step, get ready to:
  • Generate a self-signed TLS certificate with OpenSSL.
  • Configure Nginx to serve HTTPS with strong ciphers and security headers.
  • Verify the HTTPS connection and the HTTP-to-HTTPS redirect.
Generate a self-signed TLS certificate

A TLS certificate is what makes HTTPS possible. It encrypts the data flowing between the browser and your server so nobody can intercept it in transit.

  • Create a directory to store your certificate files by running this command:
sudo mkdir -p /etc/nginx/ssl
  • Generate a self-signed certificate and private key by running this command:
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/nginx/ssl/server.key \
  -out /etc/nginx/ssl/server.crt \
  -subj "/CN=production-web-server"

What does this command do?

  • req -x509 tells OpenSSL to generate a self-signed certificate instead of a certificate signing request.
  • -nodes skips encrypting the private key with a passphrase. This lets Nginx read the key without manual intervention on restart.
  • -days 365 sets the certificate to expire in one year.
  • -newkey rsa:2048 generates a new 2048-bit RSA key pair alongside the certificate.
  • -subj "/CN=production-web-server" sets the Common Name to your server's hostname, skipping the interactive prompts.
  • Set restrictive permissions on the private key and certificate by running these commands:
sudo chmod 600 /etc/nginx/ssl/server.key
sudo chmod 644 /etc/nginx/ssl/server.crt

Why different permissions for the key and certificate?

The private key (server.key) must be readable only by root (600). If anyone else can read it, they can impersonate your server. The certificate (server.crt) is public by nature, so 644 is fine.

  • Verify the files were created by running:
ls -l /etc/nginx/ssl/

You should see server.crt and server.key listed with the correct permissions.

Seeing 'command not found' for openssl?

  • OpenSSL is included in the Rocky Linux 9 Minimal installation. If the command is missing, install it by running sudo dnf install openssl -y.
  • Make sure you're running the command with sudo. Writing to /etc/nginx/ssl/ requires root privileges.
  • Help me troubleshoot my OpenSSL certificate generation.

What about Let's Encrypt in production?

A self-signed certificate is perfect for learning TLS configuration. In production with a real domain name, you would use Let's Encrypt with Certbot instead. Certbot automates certificate issuance and renewal for free.

The commands would be: sudo dnf install epel-release -y, then sudo dnf install certbot python3-certbot-nginx -y, then sudo certbot --nginx -d yourdomain.com. Your local VM does not have a public domain, so the self-signed cert is the right choice here.

Configure Nginx for HTTPS with security hardening

With the certificate ready, you need to tell Nginx to use it. You'll also add security headers that protect visitors from common web attacks and hide your server's version number from potential attackers.

  • Open your existing Nginx server block configuration by running:
sudo vi /etc/nginx/conf.d/production.conf
  • Delete all the existing content in the file. You are replacing the entire configuration.
  • Add this HTTPS server block as the first block in the file:
# HTTPS server block - serves content over TLS
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name _;

    # Certificate and private key paths
    ssl_certificate /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;

    # Only allow modern, secure TLS versions
    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;
    ssl_prefer_server_ciphers off;

    # Reuse TLS sessions to reduce handshake overhead
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    ssl_session_tickets off;

    # Hide the Nginx version number from responses
    server_tokens off;

    # Security headers to protect against common web attacks
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

    # Serve from the production web root you configured in Step 4
    root /var/www/production/html;
    index index.html;

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

What does this configuration do?

  • listen 443 ssl tells Nginx to accept encrypted connections on port 443 (the standard HTTPS port) for both IPv4 and IPv6.
  • ssl_protocols TLSv1.2 TLSv1.3 disables older, insecure TLS versions. Only 1.2 and 1.3 are considered safe today.
  • The ssl_ciphers line specifies which encryption algorithms are allowed. These are strong ECDHE + AES-GCM ciphers that all modern browsers support.
  • server_tokens off hides the Nginx version number from HTTP responses. Attackers scan for specific versions with known vulnerabilities.
  • The five add_header lines are security headers. They prevent MIME type sniffing, clickjacking, cross-site scripting, referrer leaking, and access to browser features like the camera or microphone.

You won't see a change yet. The redirect block below completes the configuration.

  • Below the closing } of the HTTPS server block, add this HTTP redirect block:
# Redirect all HTTP traffic to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name _;

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

What does the redirect block do?

This catches any request that comes in on port 80 (plain HTTP) and sends back a 301 permanent redirect to the same URL over HTTPS. The browser follows the redirect automatically. This means nobody can accidentally access your site over an unencrypted connection.

  • Save and close the file.
  • Test the Nginx configuration for syntax errors by running:
sudo nginx -t

You should see 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?

  • Check that you deleted ALL of the old server block content before pasting the new configuration. Leftover lines from the previous config cause parse errors.
  • Make sure every opening { has a matching closing }. The file should have exactly two server blocks.
  • Verify the certificate paths match exactly: /etc/nginx/ssl/server.crt and /etc/nginx/ssl/server.key. A typo in either path causes Nginx to fail.
  • Help me fix my Nginx configuration syntax error.

Awesome, I've got everything!

Double check that you've saved production.conf and that sudo nginx -t passes with no errors.

I'd like to double check the full code

Here is the complete production.conf file. Make sure yours matches exactly.

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name _;

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

    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;
    ssl_prefer_server_ciphers off;

    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    ssl_session_tickets off;

    server_tokens off;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

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

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

server {
    listen 80;
    listen [::]:80;
    server_name _;

    return 301 https://$host$request_uri;
}
Verify HTTPS and security headers

Configuration is only half the job. You need to confirm that HTTPS is working, that your security headers are present, and that HTTP requests redirect properly.

  • Test the HTTPS response headers from your server by running:
curl -kI https://localhost

What do the -kI flags mean?

  • -k tells curl to accept self-signed certificates without complaining.
  • -I fetches only the response headers, not the full page body. This is exactly what you need to verify your security headers.

You should see a HTTP/1.1 200 OK response with your security headers listed.

  • Check that each of these headers appears in the output:
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: SAMEORIGIN
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: geolocation=(), microphone=(), camera=()
  • Verify that server_tokens off is working by looking at the Server header in the output. It should show just nginx without a version number.

Why hide the Nginx version?

Attackers scan servers looking for specific software versions with known vulnerabilities. Removing the version number from the Server header does not make your server invulnerable, but it eliminates one piece of information that automated scanners use to target you.

  • On your Windows machine, open a web browser.
  • Navigate to https:// followed by your VM's IP address.

You will see a certificate warning because the certificate is self-signed. This is expected.

  • Click Advanced on the warning page, then click Continue to site (or Accept the Risk and Continue in Firefox).

You should see your production web page served over HTTPS. The browser address bar shows a lock icon with a warning indicator because the certificate is self-signed.

  • Test the HTTP-to-HTTPS redirect by navigating to http:// followed by your VM's IP address (no s in http).

The browser should automatically redirect to the HTTPS version. Check the address bar to confirm it now shows https://.

Page not loading or redirect not working?

  • Run sudo firewall-cmd --list-all and confirm that both http and https are listed under services. You added these in Step 3, but double check they survived a reload.
  • Check that Nginx is still running with sudo systemctl status nginx. If the reload failed silently, Nginx may be stopped.
  • If the redirect loops, make sure you have two separate server blocks. The port 443 block should NOT have a return 301 directive. Only the port 80 block should redirect.
  • Help me troubleshoot my Nginx HTTPS configuration.

Your server is now encrypted and hardened. Every connection uses TLS, every response carries security headers, and the Nginx version is hidden from attackers. Next up, you'll configure production logging to track everything that happens on your server.

Configure Production Logging

Your Nginx server is now encrypted with TLS and hardened with security headers. One critical layer remains: logging.

Without persistent logging, every reboot wipes your journal. Without log rotation, Nginx logs grow until they fill the disk. You will configure journald for persistent storage with size limits and set up logrotate to manage Nginx logs automatically.

In this step, get ready to:
  • Configure journald for persistent log storage with retention limits.
  • Set up logrotate for automatic Nginx log management.
  • Run a full security audit to verify every hardening layer.
Configure persistent journald storage

On Rocky Linux 9, journald stores logs in volatile memory by default. That means every reboot wipes your logs completely.

For a production server, you need persistent storage so logs survive reboots. You also need size limits so logs do not consume all your disk space over time.

  • Check the current journal storage status by running these commands:
journalctl --disk-usage
ls -la /var/log/journal/

What should I see?

The journalctl --disk-usage command shows how much disk space the journal uses. If the ls command shows No such file or directory, that confirms your logs are stored in volatile memory at /run/log/journal/ and are wiped on every reboot.

  • Create the persistent storage directory and set proper ownership by running these commands:
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal

What does systemd-tmpfiles do?

The mkdir command creates the directory, but systemd-tmpfiles applies the correct ownership and permissions that systemd expects for journal storage. Without it, journald may not be able to write to the directory.

  • Confirm the directory was created by running:
ls -la /var/log/journal/

You should see the /var/log/journal/ directory listed with a machine-ID subdirectory inside it.

Now you need a configuration file that tells journald to use persistent storage and sets size limits. Drop-in configuration files let you override defaults without editing the main config.

  • Create the configuration directory by running:
sudo mkdir -p /etc/systemd/journald.conf.d/
  • Open a new configuration file by running:
sudo vi /etc/systemd/journald.conf.d/persistent.conf
  • Add the following configuration:
[Journal]
Storage=persistent
SystemMaxUse=500M
SystemMaxFileSize=50M
SystemKeepFree=500M
MaxRetentionSec=30day
Compress=yes

What does this configuration do?

  • Storage=persistent tells journald to write logs to /var/log/journal/ instead of volatile memory.
  • SystemMaxUse=500M caps total journal disk usage at 500 MB.
  • SystemMaxFileSize=50M limits individual journal files to 50 MB each.
  • SystemKeepFree=500M ensures at least 500 MB of free disk space is always reserved.
  • MaxRetentionSec=30day automatically deletes logs older than 30 days.
  • Compress=yes compresses archived journal files to save space.
  • Save the file and exit vi by pressing Esc, typing :wq, and pressing Enter.

The configuration takes effect after you restart journald below.

Seeing errors after saving?

  • Make sure the file starts with [Journal] on its own line. Without the section header, journald ignores every directive below it.
  • Check for typos in directive names. For example, SystemMaxUse is case-sensitive.
  • Confirm the file is saved to /etc/systemd/journald.conf.d/persistent.conf and not to the wrong directory.

Help me troubleshoot my journald configuration.

Awesome, I've got everything!

Great. Make sure you have saved persistent.conf before continuing.

I'd like to double check the full code

[Journal]
Storage=persistent
SystemMaxUse=500M
SystemMaxFileSize=50M
SystemKeepFree=500M
MaxRetentionSec=30day
Compress=yes
  • Restart journald to apply your changes by running:
sudo systemctl restart systemd-journald
  • Verify that persistent storage is active by running:
journalctl --disk-usage
ls -la /var/log/journal/

You should see storage usage reported against /var/log/journal/ and a machine-ID subdirectory populated with journal files.

  • Test querying Nginx logs by running:
journalctl -u nginx.service --no-pager -n 20

You should see the 20 most recent Nginx log entries.

  • View logs from the current boot session by running:
journalctl -b 0 --no-pager | tail -20

You should see the last 20 log entries from this boot. These logs now persist across reboots.

Still seeing volatile storage?

  • Run cat /etc/systemd/journald.conf.d/persistent.conf and confirm the file content matches the configuration above.
  • Make sure you restarted journald with sudo systemctl restart systemd-journald after saving the file. Changes do not apply until the service restarts.

Help me fix persistent journald storage on Rocky Linux 9.

Configure Nginx log rotation

Journald handles system-level logs. Nginx also writes its own access and error logs to /var/log/nginx/. Without rotation, these files grow indefinitely.

Logrotate automatically rotates, compresses, and deletes old log files on a schedule you define.

  • Confirm logrotate is installed by running:
logrotate --version

You should see a version number printed. Logrotate is included in the Rocky Linux Minimal installation.

  • Check if a default Nginx logrotate config already exists by running:
cat /etc/logrotate.d/nginx

The Nginx package may have installed a default config here. You will replace it with a production-grade configuration.

  • Open the Nginx logrotate configuration file by running:
sudo vi /etc/logrotate.d/nginx
  • Replace the entire file content with the following configuration:
/var/log/nginx/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 nginx nginx
    sharedscripts
    postrotate
        /usr/bin/kill -USR1 $(cat /var/run/nginx.pid 2>/dev/null) 2>/dev/null || true
    endscript
}

What does this configuration do?

  • daily rotates log files once per day.
  • rotate 14 keeps 14 days of old logs before deleting them.
  • compress and delaycompress gzip old logs, but delay compression by one cycle so the most recent rotated file stays readable.
  • create 0640 nginx nginx creates a fresh empty log file with restricted permissions owned by the nginx user.
  • The postrotate script sends the USR1 signal to Nginx, which tells it to reopen its log files. Without this, Nginx keeps writing to the old rotated file.
  • Save the file and exit vi by pressing Esc, typing :wq, and pressing Enter.

Getting errors when saving?

  • Make sure you opened the file with sudo. Without it, vi cannot write to /etc/logrotate.d/.
  • Check that the opening brace { is on the same line as the log path and the closing brace } is on its own line at the end.

Help me troubleshoot my logrotate configuration.

Awesome, I've got everything!

Great. Make sure you have saved /etc/logrotate.d/nginx before continuing.

I'd like to double check the full code

/var/log/nginx/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 nginx nginx
    sharedscripts
    postrotate
        /usr/bin/kill -USR1 $(cat /var/run/nginx.pid 2>/dev/null) 2>/dev/null || true
    endscript
}
  • Test the logrotate configuration with a dry run by running:
sudo logrotate --debug /etc/logrotate.d/nginx

The debug output walks through what logrotate would do without actually rotating anything. Verify that no errors appear in the output.

Seeing errors in the dry run?

  • If you see error: bad rotation count, check that rotate 14 appears on its own line with no extra characters.
  • If you see error: missing closing brace, make sure your endscript and closing } are each on their own lines.

Help me debug my logrotate dry run errors.

Run a full security audit

Every layer of your server is now configured. Before calling this complete, you need to verify that every hardening measure is active and working together.

  • Run the following commands one at a time to audit each security layer:
  • Check SELinux status by running:
sestatus

Confirm that SELinux status shows enabled and current mode shows enforcing.

  • Check firewalld rules by running:
sudo firewall-cmd --list-all

Confirm that the services line shows only ssh, http, and https. No other ports should be open.

  • Check SSH hardening by running:
sudo sshd -T | grep -E "permitrootlogin|passwordauthentication"

Both values should show no. Root login is blocked and password authentication is disabled.

  • Verify the Nginx configuration by running:
sudo nginx -t

You should see syntax is ok and test is successful.

  • Test HTTPS and security headers by running:
curl -kI https://localhost

You should see a 200 response with security headers including X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. The Server header should show just nginx with no version number.

  • Confirm persistent log storage by running:
journalctl --disk-usage

Storage should be reported under /var/log/journal/.

  • Confirm you are running as your admin user by running:
whoami

This should show sysadmin, not root. A production server should never be operated as root.

What did you just verify?

This audit confirmed seven layers of production hardening are active at the same time:

  • SELinux is enforcing mandatory access control policies.
  • Firewalld limits inbound traffic to only SSH, HTTP, and HTTPS.
  • SSH blocks root login and password-based authentication.
  • Nginx has valid TLS encryption with security headers.
  • Journald stores logs persistently with size and retention limits.
  • Logrotate manages Nginx log files automatically.
  • The server is operated through a dedicated admin user with sudo access.

Your server is production-hardened. Every layer is configured, verified, and working together.

Secret mission

Add Prometheus Node Exporter for Production Monitoring

Your server is hardened. But in production, hardened is not enough. You need to see what your server is doing. Prometheus Node Exporter is the industry standard for exposing system metrics like CPU, memory, disk, and network usage. In this secret mission, you will install Node Exporter, run it as a proper systemd service, and lock it down with firewalld and SELinux.

Before you go

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 locally on your machine using VMware Workstation Pro, so there are no ongoing cloud costs.

Resources you used:

  • Rocky Linux 9 VM in VMware Workstation (20 GB disk space).
  • SSH key pair stored at $HOME\.ssh\production-server-key and $HOME\.ssh\production-server-key.pub on your Windows machine.
  • Nginx web server with TLS certificate files in /etc/nginx/ssl/.
  • Custom web root at /var/www/production/html/.
  • firewalld rules, SELinux policy modifications, journald configuration, and logrotate configuration (all inside the VM).

Keep everything running

No action needed. Choose this if you are still actively building or plan to use this VM as a reference server for future projects.

  • Your VM will remain in VMware Workstation and can be powered on at any time.
  • The VM uses approximately 20 GB of disk space on your Windows machine.
  • When powered on, the VM uses 2 GB of RAM from your host system.

Pause - I'll come back to this later

Shut down the VM to free up memory while keeping all your files and configuration intact.

  • In your VM terminal, run sudo shutdown now to cleanly power off the VM.
  • Alternatively, in VMware Workstation select the VM and click Suspend to save the VM state to disk (like hibernation).
  • The VM files remain on disk (approximately 20 GB) but no RAM is consumed while the VM is off.
  • To resume, open VMware Workstation and power on the production-web-server VM.

Delete - I don't want to use this again

Remove all project resources and reclaim disk space on your Windows machine.

  • In VMware Workstation, right-click the production-web-server VM and select Manage, then Delete from Disk. This permanently removes the VM and its virtual disk.
  • Delete the SSH key pair from your Windows machine by running del $HOME\.ssh\production-server-key and del $HOME\.ssh\production-server-key.pub in PowerShell.
  • Remove the known hosts entry for the VM IP address by editing $HOME\.ssh\known_hosts and deleting the line containing your VM's IP.

Mission accomplished

That's a Wrap!

You just deployed and hardened a production web server on Rocky Linux 9. That is not a lab exercise. That is a production skill you can demonstrate to any employer who hires Linux administrators.

Here's what you learned:

  • Deployed Nginx on Rocky Linux 9 with HTTPS, TLS hardening, security headers, and firewalld rules that only allow the traffic you explicitly permit.
  • Configured SELinux in Enforcing mode with proper file contexts and booleans for Nginx, instead of disabling it like most tutorials tell you to do.
  • Hardened SSH with key-only authentication and set up persistent journald logging with retention limits and Nginx log rotation.
  • Added Prometheus Node Exporter for production monitoring with proper SELinux port labeling and firewall rules scoped to trusted IPs only.

Ready to quiz yourself?