Harden a Rocky Linux Server on Azure
Provision and harden a Rocky Linux 9 VM on Azure with Docker and systemd.
Introduction
30 Second Summary
Every website, app, and online service you use runs on a server somewhere. Companies need engineers who can set up these servers securely, deploy services onto them, and keep them running reliably.
In this project, you will provision a Rocky Linux 9 server on Azure, harden it against attacks, deploy a containerized web server, and build a monitoring script that reports on server health.
What You'll Build
You can SSH into your own cloud server, show a live web page served from a Docker container that auto-recovers from crashes, and pull up timestamped health reports proving everything is running smoothly.
By the end of this project, you'll have:
- A hardened cloud server you can SSH into securely with key-based authentication, knowing root login is disabled and the firewall only allows traffic you explicitly permit.
- A live NGINX web page served from a Docker container managed by systemd, which auto-restarts on failure and starts on boot.
- A health monitoring system powered by a custom bash script that checks container state, disk usage, and memory on a schedule and writes timestamped reports.
- Secret Mission: Build an incident alerting system that detects container failures and writes detailed incident reports.
Are there any prerequisites?
None. This project includes setup instructions for every tool you need, including Azure CLI and an Azure free account. You just need a Windows machine with PowerShell available.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Azure CLI and Create Your Free Account
To provision and manage your Linux server on Azure, you need a way to talk to Azure from the command line. The Azure CLI lets you create virtual machines, configure networking, and manage resources without ever leaving your terminal.
In this step, you will install the Azure CLI, create a free Azure account, and sign in so you are ready to provision your Rocky Linux server in Step 2.
In this step, get ready to:
- Install Azure CLI on Windows using WinGet.
- Create an Azure free account.
- Sign in to Azure CLI and verify your subscription is active.
Install Azure CLI on Windows using WinGet
The Azure CLI is a command-line tool that lets you manage Azure resources directly from PowerShell. Let's check whether you already have it installed.
- Switch back to your PowerShell window and check for the Azure CLI by running this command:
az version
✔️ I see version 2.88.0 or higher
You already have the Azure CLI at the required version. Skip ahead to the next substep, Create an Azure free account.
ⓧ I see an older version
You have a version lower than 2.88.0. Upgrade to the latest version by running this command:
winget upgrade --id Microsoft.AzureCLI
- Close your terminal completely after the upgrade finishes.
- Reopen PowerShell.
- Run az version again and confirm you now see 2.88.0 or higher.
Still seeing the old version?
Make sure you fully closed and reopened your terminal. The new version only loads in a fresh session.
help me upgrade Azure CLI on Windows
ⓧ Command not found
Azure CLI is not installed yet. Install it by running this command:
winget install --exact --id Microsoft.AzureCLI
What does this command do?
WinGet is the built-in Windows package manager. The --exact flag ensures the ID matches precisely, and --id Microsoft.AzureCLI tells WinGet exactly which package to install.
- Wait for the installation to complete. You will see a progress bar in the terminal.
- Close your terminal completely after installation finishes.
- Reopen PowerShell.
- Verify the installation by running:
az version
You should see a JSON output with "azure-cli": "2.88.0" (or a higher version number).
Still getting command not found?
Make sure you completely closed and reopened your terminal after installation. WinGet updates the system PATH, but existing terminals do not pick up PATH changes.
help me troubleshoot Azure CLI installation on Windows
Create an Azure free account
You need an Azure account to host your Linux server. The free tier includes $200 credit for 30 days and 750 hours per month of a B2ats v2 VM for 12 months.
- Open your browser and navigate to https://azure.microsoft.com/en-us/free/.
- Click Start free.
- Sign in with an existing Microsoft account, or create a new one.
- Complete the identity verification. This requires a credit card but will not charge you anything.
- Once the signup is complete, you will see the Azure portal dashboard.
Why does Azure need my credit card?
Azure uses it for identity verification only. You will not be charged unless you manually upgrade to a paid plan. The free account gives you $200 in credit for the first 30 days plus 12 months of select free services.
Sign in to Azure CLI and verify your subscription
Now that your account exists, you need to connect your local Azure CLI to it. This lets you run commands that create resources in your Azure subscription.
- Return to your PowerShell window and sign in by running:
az login
What happens when you run this?
A browser window opens automatically. Sign in with the same Microsoft account you used to create your Azure free account. After authentication completes, the browser will say you can close the window and your terminal will show your subscription details.
Before you run the next command, what do you think you will see? A subscription name and a state value. Can you guess what the state should be?
- Verify your subscription is active by running:
az account show --output table
You should see a table with your subscription name and the state column showing Enabled. This confirms your CLI is connected to a working Azure subscription.
Seeing a login error or empty output?
If the browser did not open, try running az login again. Make sure you sign in with the same account you used for the free tier signup.
If you see a subscription but the state is not Enabled, your free account setup may not have completed. Return to the Azure portal and confirm your subscription is active.
help me fix my Azure CLI login
Your Azure CLI is installed and authenticated. Next up, you will use it to provision a Rocky Linux 9 virtual machine on Azure.
Provision a Rocky Linux 9 VM
Your Azure CLI is connected and your free account is active. Now you need an actual Linux server to configure and harden.
You'll provision a Rocky Linux 9 VM using Azure CLI commands directly from PowerShell. Provisioning from the command line (not the portal GUI) is how engineers work in production environments and what interviewers expect you to demonstrate.
In this step, get ready to:
- Create a resource group and provision a Rocky Linux 9 VM.
- SSH into the server and confirm the operating system.
- Open HTTP port 80 for the web server you'll deploy later.
Create a resource group
Every Azure resource lives inside a resource group. Think of it as a folder that holds all the related pieces of your project together.
- Create your resource group by running this command in PowerShell:
az group create --name rg-linux-lab --location eastus
You should see JSON output containing "provisioningState": "Succeeded". This confirms your resource group exists.
What is a resource group?
A resource group is a logical container in Azure that holds related resources (VMs, disks, network interfaces, IP addresses) so you can manage them as a single unit. When you delete the resource group later, everything inside it gets cleaned up automatically.
Provision the Rocky Linux 9 VM
Now you'll create the actual virtual machine. This single command provisions a VM with SSH key authentication, a public IP, and the Rocky Linux 9 image.
- Provision your VM by running this command:
az vm create --resource-group rg-linux-lab --name rocky-server --image erockyenterprisesoftwarefoundationinc1653071250513:rockylinux:9-base:latest --size Standard_B2ats_v2 --admin-username azureuser --generate-ssh-keys --public-ip-sku Standard
What does this command do?
- --resource-group rg-linux-lab places the VM inside the resource group you just created.
- --name rocky-server gives your VM a name you can reference later.
- --image specifies the Rocky Linux 9 marketplace image.
- --size Standard_B2ats_v2 selects the free-tier eligible AMD-based burstable VM.
- --generate-ssh-keys creates an SSH key pair for secure access (or reuses your existing one).
- --public-ip-sku Standard assigns a Standard SKU public IP so you can reach the VM from the internet.
This command takes 1-2 minutes to complete. When it finishes, you'll see JSON output with a publicIpAddress field containing your VM's IP address.
- Find the publicIpAddress value in the output and write it down. You'll use this IP address throughout the project.
Seeing a marketplace image error?
- If you see a message about the image not being found, confirm you typed the full image reference exactly as shown, including the long publisher name.
- If you see a quota or size error, your subscription may not have the B2ats v2 size available in eastus. Try a different region by changing --location in both the resource group and VM commands.
Still stuck? Help me troubleshoot my az vm create error
SSH into the server
Your VM is running. Time to connect to it and confirm it's the right operating system.
Before you run the next command, what do you expect to see if the VM was provisioned correctly?
- Connect to your VM over SSH by running:
ssh azureuser@[[PUBLIC_IP="your VM's public IP address"]]
- When prompted about the host fingerprint, type yes and press **Enter** to accept.
You should see a shell prompt that looks like [azureuser@rocky-server ~]$. You're now logged into your Rocky Linux server.
- Confirm the operating system by running:
cat /etc/os-release
You should see output confirming Rocky Linux 9 in the NAME and VERSION fields.
- Exit the SSH session to return to your local PowerShell by running:
exit
Connection timed out or refused?
- Double check you copied the correct IP address from the az vm create output.
- Wait 30 seconds and try again. The VM may still be booting.
- Make sure you're running the SSH command from your local PowerShell, not from inside another session.
Need more help? Help me debug my SSH connection to the Azure VM
Open HTTP port for later use
Later in this project, you'll deploy an NGINX web server. For the browser to reach it, port 80 needs to be open in the VM's network security group.
- Open port 80 by running this command from your local PowerShell:
az vm open-port --resource-group rg-linux-lab --name rocky-server --port 80
You'll see JSON output describing the updated network security group rule. This adds an inbound rule allowing HTTP traffic on port 80.
Why open the port now?
Azure VMs block all inbound traffic by default except SSH (port 22). The az vm open-port command adds a network security group rule so HTTP requests can reach your server when you deploy NGINX later.
Getting a permission or resource error?
- Make sure you're running this from your local PowerShell, not from inside the VM's SSH session.
- Confirm your Azure CLI is still signed in by running az account show. If it prompts you to log in again, run az login.
Still stuck? Help me fix my az vm open-port error
Your server is live and reachable. Next up, you'll lock it down by hardening SSH, configuring the firewall, and creating a dedicated admin user.
Harden the Server
Your Rocky Linux VM is running and reachable over SSH. That means anyone who knows the public IP could attempt a brute-force password attack, log in as root, or hit services you never intended to expose.
In this step, you'll lock down the server the same way a Linux Cloud Engineer would on day one: create a dedicated admin user, disable password-based logins, block root access over SSH, and configure firewalld to allow only the traffic your project needs.
In this step, get ready to:
- Create a dedicated admin user with SSH key access.
- Harden SSH to reject password logins and root access.
- Configure firewalld to permit only SSH and HTTP traffic.
Create a dedicated admin user and configure sudo
Running everything as azureuser is fine for initial setup, but production servers use a dedicated admin account. This separates day-to-day administration from the default provisioning user.
- SSH into your VM by running this command in PowerShell:
ssh azureuser@[[PUBLIC_IP="your VM's public IP address"]]
Find your public IP
You noted this IP from the az vm create output in the previous step. If you lost it, run az vm show -d --resource-group rg-linux-lab --name rocky-server --query publicIps -o tsv from your local PowerShell.
- Create the linuxadmin user and grant it sudo privileges by running these commands:
# Create a new user with a home directory and bash shell
sudo useradd -m -s /bin/bash linuxadmin
# Add the user to the wheel group (grants sudo access on RHEL-family systems)
sudo usermod -aG wheel linuxadmin
What does the wheel group do?
On Rocky Linux and other RHEL-family distributions, the wheel group grants members permission to run commands with sudo. Adding linuxadmin to this group means it can perform administrative tasks.
- Confirm the user was created and belongs to the correct groups by running:
id linuxadmin
You should see output showing uid, gid, and groups that includes wheel.
- Copy the SSH authorized keys from azureuser to linuxadmin by running this command:
sudo mkdir -p /home/linuxadmin/.ssh && sudo cp ~/.ssh/authorized_keys /home/linuxadmin/.ssh/ && sudo chown -R linuxadmin:linuxadmin /home/linuxadmin/.ssh && sudo chmod 700 /home/linuxadmin/.ssh && sudo chmod 600 /home/linuxadmin/.ssh/authorized_keys
What does this command do?
This creates the .ssh directory in the new user's home folder, copies your existing public key into it, then sets ownership and permissions. The 700 permission on .ssh and 600 on authorized_keys are required by SSH. If permissions are too open, SSH silently refuses key-based login.
- Open a new PowerShell window on your local machine (keep the current session open as a safety net).
- Test SSH access as linuxadmin by running:
ssh linuxadmin@[[PUBLIC_IP="your VM's public IP address"]]
You should see the Rocky Linux prompt as linuxadmin.
Can't log in as linuxadmin?
Make sure you ran the key-copy command as azureuser (so ~/.ssh/authorized_keys refers to azureuser's key). Verify permissions by running ls -la /home/linuxadmin/.ssh/ on the VM. The .ssh directory must be drwx------ and authorized_keys must be -rw-------.
help me troubleshoot my linuxadmin SSH login failing on Rocky Linux 9
Harden SSH configuration
Your server currently allows password-based logins. An attacker could brute-force their way in without needing your SSH key. You'll disable password authentication and block root login entirely.
- From your linuxadmin session on the VM, open the SSH daemon config file by running:
sudo vi /etc/ssh/sshd_config
Quick vi commands you'll need
- Press / to search. Type the setting name and press Enter to jump to it.
- Press i to enter insert mode and make your edit.
- Press Esc to exit insert mode.
- Type :wq and press Enter to save and quit.
- Find the line containing PermitRootLogin (it may be commented out with a #). Change it to:
PermitRootLogin no
- Find the line containing PasswordAuthentication and change it to:
PasswordAuthentication no
- Find the line containing PubkeyAuthentication and ensure it reads:
PubkeyAuthentication yes
- If any of these lines start with #, remove the # to uncomment them.
- Save and exit vi by pressing Esc, then typing :wq and pressing Enter.
Why check for drop-in files?
Rocky Linux 9 uses an Include /etc/ssh/sshd_config.d/*.conf directive at the top of sshd_config. Drop-in files loaded earlier can override your changes. If you see unexpected behaviour after these edits, check that no file in /etc/ssh/sshd_config.d/ sets PasswordAuthentication yes.
- Verify your changes took effect by running:
sudo grep -E '^(PermitRootLogin|PasswordAuthentication|PubkeyAuthentication)' /etc/ssh/sshd_config
You should see all three settings displayed with their new values.
- Restart the SSH daemon to apply the changes by running:
sudo systemctl restart sshd
Before you test, think about what should happen if you try to connect with only a password and no SSH key. What do you expect to see?
- Open another new PowerShell window on your local machine and attempt a password-only connection by running:
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no linuxadmin@[[PUBLIC_IP="your VM's public IP address"]]
The connection is immediately rejected with a permission denied message. That confirms password authentication is disabled.
Still able to log in with a password?
Check for override files in /etc/ssh/sshd_config.d/ that may set PasswordAuthentication yes. Run sudo grep -r PasswordAuthentication /etc/ssh/sshd_config.d/ to find any conflicts. Also confirm you restarted sshd after making changes.
help me debug why password authentication is still working after I disabled it in sshd_config
Configure firewalld
SSH hardening keeps attackers out of the login prompt, but the server still listens on every port the OS exposes. A properly configured firewall ensures only the services you choose can receive traffic.
- Check that firewalld is running by executing:
sudo systemctl status firewalld
You should see active (running) in green text. Firewalld comes pre-installed and active on Rocky Linux 9.
Firewalld not active?
If the status shows inactive, enable and start it by running:
sudo systemctl enable --now firewalld
help me get firewalld running on Rocky Linux 9
- Set the default zone to public and allow only SSH and HTTP traffic by running these commands:
# Set the default firewall zone
sudo firewall-cmd --set-default-zone=public
# Permanently allow SSH traffic
sudo firewall-cmd --permanent --add-service=ssh
# Permanently allow HTTP traffic
sudo firewall-cmd --permanent --add-service=http
Each command prints success when it completes.
What does --permanent mean?
Without --permanent, firewall rules apply immediately but are lost on reboot. With --permanent, rules persist across reboots but don't take effect until you reload. You'll reload in the next command.
- Remove the cockpit service (enabled by default on some Rocky Linux images) and reload the firewall by running:
# Remove cockpit if present (suppresses an error if not found)
sudo firewall-cmd --permanent --remove-service=cockpit 2>/dev/null
# Reload to apply all permanent rules
sudo firewall-cmd --reload
Now verify the final firewall state. Before you run the next command, predict which services you expect to see listed.
- Display the active firewall rules by running:
sudo firewall-cmd --list-all
In the services line, you should see only ssh and http. Nothing else should be listed.
Seeing extra services listed?
If other services like dhcpv6-client or cockpit still appear, remove them with sudo firewall-cmd --permanent --remove-service=<service-name> and run sudo firewall-cmd --reload again.
help me remove unwanted services from my firewalld configuration
Your server is now hardened. Password login is disabled, root cannot SSH in, and the firewall permits only SSH and HTTP traffic. In the next step, you'll put that open HTTP port to use by deploying an NGINX container managed by systemd.
Deploy NGINX in Docker with systemd
Your server is locked down with key-only SSH and a properly configured firewall. Now it is time to put it to work by running a web server.
In production, engineers run web servers inside containers managed by systemd. This pattern ensures your service auto-restarts on failure and starts on boot. It is a common interview question: "How do you ensure a container runs as a system service?"
In this step, get ready to:
- Install Docker CE on your Rocky Linux 9 server.
- Create a systemd unit file that manages an NGINX container.
- Test deployment and verify auto-restart resilience.
Install Docker on Rocky Linux 9
Docker is not included with Rocky Linux 9 by default. You will install it from the official Docker repository for CentOS-compatible systems.
- SSH into your VM as linuxadmin by running:
ssh linuxadmin@[[PUBLIC_IP="your VM's public IP address"]]
- Add the official Docker repository by running:
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
- Install Docker CE and its dependencies by running:
sudo dnf install -y docker-ce docker-ce-cli containerd.io
You will see packages being downloaded and installed. This takes about a minute.
- Start the Docker service and enable it to run on boot by running:
sudo systemctl enable --now docker
- Confirm Docker is running by checking its status:
sudo systemctl status docker
You should see Active: active (running) in the output. Press q to exit the status view.
Seeing "inactive" or an error?
Make sure you typed enable --now (not just enable). The --now flag starts the service immediately in addition to enabling it for boot.
If you see a repository error, confirm your VM has internet access by running ping -c 2 google.com.
help me troubleshoot Docker not starting on Rocky Linux 9
- Add linuxadmin to the docker group so you can run Docker commands without sudo:
sudo usermod -aG docker linuxadmin
- Log out of the SSH session by running:
exit
- Log back in so the group membership takes effect:
ssh linuxadmin@[[PUBLIC_IP="your VM's public IP address"]]
- Verify Docker works without sudo by running:
docker --version
You should see a version number printed, confirming Docker is installed and your user has permission to run it.
Why add your user to the docker group?
By default, Docker commands require sudo because the Docker daemon runs as root. Adding your user to the docker group grants permission to interact with the daemon without sudo on every command.
Create a systemd unit file for the NGINX container
A systemd unit file tells Linux how to manage a service. You will create one that runs an NGINX container, restarts it automatically on failure, and starts it every time the server boots.
- Create the unit file by running:
sudo vi /etc/systemd/system/nginx-container.service
- Press i to enter insert mode, then paste the following content:
[Unit]
Description=NGINX Container
After=docker.service
Requires=docker.service
[Service]
Restart=always
ExecStartPre=-/usr/bin/docker rm -f nginx-web
ExecStart=/usr/bin/docker run --name nginx-web -p 80:80 nginx:stable
ExecStop=/usr/bin/docker stop nginx-web
[Install]
WantedBy=multi-user.target
What does this unit file do?
- [Unit] declares that this service depends on Docker. systemd will not start it until Docker is ready.
- ExecStartPre=- removes any leftover container from a previous run. The - prefix means "do not fail if the container does not exist."
- ExecStart runs the NGINX container on port 80 using the nginx:stable tag (a production best practice over latest).
- Restart=always tells systemd to bring the container back up if it ever stops unexpectedly.
- WantedBy=multi-user.target makes the service start automatically on boot.
- Save and exit vi by pressing Esc, then typing :wq and pressing Enter.
- Tell systemd to pick up the new unit file by running:
sudo systemctl daemon-reload
- Enable and start the NGINX container service by running:
sudo systemctl enable --now nginx-container
Docker will pull the nginx:stable image the first time this runs. Wait for the pull to complete.
- Verify the service is running:
sudo systemctl status nginx-container
You should see Active: active (running) in the output. Press q to exit.
Service showing "failed" or "activating"?
Check the logs with sudo journalctl -u nginx-container --no-pager -n 20 to see what went wrong.
If you see a "port already in use" error, another process may be on port 80. Run sudo ss -tlnp | grep :80 to find it.
If Docker cannot pull the image, verify internet connectivity with ping -c 2 docker.io.
help me troubleshoot my nginx-container systemd service failing to start
✔️ Awesome, I've got everything!
Great. Double check you saved the file and the service shows active.
ⓧ I'd like to double check the full code
Here is the complete unit file at /etc/systemd/system/nginx-container.service:
[Unit]
Description=NGINX Container
After=docker.service
Requires=docker.service
[Service]
Restart=always
ExecStartPre=-/usr/bin/docker rm -f nginx-web
ExecStart=/usr/bin/docker run --name nginx-web -p 80:80 nginx:stable
ExecStop=/usr/bin/docker stop nginx-web
[Install]
WantedBy=multi-user.target
Test the deployment
The container is running and managed by systemd. Time to verify it is actually serving web traffic and that the auto-restart works.
Before you check the browser, predict: what do you think you will see at your server's public IP?
- Open your local web browser and navigate to:
http://[[PUBLIC_IP="your VM's public IP address"]]
You should see the NGINX welcome page with the heading "Welcome to nginx!"
Not seeing the welcome page?
Confirm the container is running with docker ps. You should see nginx-web listed.
Make sure you opened port 80 on the Azure NSG back in Step 2. Verify with az vm open-port --resource-group rg-linux-lab --name rocky-server --port 80 from your local PowerShell.
Also verify firewalld allows HTTP by running sudo firewall-cmd --list-services on the VM. You should see http in the list.
help me troubleshoot why I cannot access NGINX from my browser
Now test that systemd automatically restarts the container if it stops.
- Manually stop the NGINX container by running:
sudo docker stop nginx-web
- Wait about 5 seconds, then check if systemd brought it back:
sudo systemctl status nginx-container
You should see Active: active (running) again. systemd detected the container stopped and restarted it automatically. This is the production resilience pattern that interviewers ask about.
Why systemd instead of Docker restart policies?
Docker has its own --restart flag, but systemd gives you centralized logging with journalctl, dependency ordering (start after Docker is ready), and the same management commands (systemctl start/stop/status) used for all other Linux services.
In an interview, being able to explain why you chose systemd over Docker's built-in restart shows you understand the broader Linux service ecosystem.
Your server now runs a containerized web server that survives reboots and self-heals from failures. Next up, you will write a bash script that monitors the health of this container and logs reports.
Build a Health Monitoring Script
Your NGINX container is running as a production-grade systemd service that restarts automatically on failure. The next question an interviewer will ask is: "How do you monitor server health?"
Bash scripting for health monitoring is a core Linux engineer skill. In this step, you'll write a script that checks your container's status, disk usage, and available memory, then logs a timestamped report to a file.
In this step, get ready to:
- Write a bash health-check script that monitors container, disk, and memory status.
- Run the script and verify its log output.
- Schedule the script to run automatically every 5 minutes with cron.
Write the health check script
A good monitoring script checks the services you care about and logs results with timestamps. Your script will inspect three things: whether your NGINX container is running, how much disk space is used, and how much memory is available.
- Create the script file by opening it in the vi editor:
sudo vi /usr/local/bin/health-check.sh
- Press i to enter insert mode, then paste the following script:
#!/bin/bash
LOG_FILE="/var/log/server-health.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "===== Health Check: $TIMESTAMP =====" >> "$LOG_FILE"
# Check container status
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' nginx-web 2>/dev/null)
if [ "$CONTAINER_STATUS" == "running" ]; then
echo "[OK] NGINX container is running" >> "$LOG_FILE"
else
echo "[ALERT] NGINX container is NOT running (status: $CONTAINER_STATUS)" >> "$LOG_FILE"
fi
# Disk usage
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')
echo "[INFO] Disk usage: $DISK_USAGE" >> "$LOG_FILE"
# Memory usage
MEM_AVAILABLE=$(free -m | awk 'NR==2 {print $7}')
echo "[INFO] Available memory: ${MEM_AVAILABLE}MB" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"
What does this script do?
The script defines a log file path and captures the current timestamp. Every time it runs, it appends a new section to the log.
- `docker inspect` checks whether the `nginx-web` container is running. If not, it logs an alert with the current status.
- `df -h /` reports disk usage as a percentage. The `awk` command extracts just the percentage value from the output.
- `free -m` shows memory in megabytes. The script grabs the "available" column, which is what the system can still allocate.
- Press Esc, then type :wq and press Enter to save and exit vi.
- Make the script executable by running:
sudo chmod +x /usr/local/bin/health-check.sh
- Confirm the file exists and is executable by running:
ls -l /usr/local/bin/health-check.sh
You should see -rwxr-xr-x at the start of the output, confirming the execute permission is set.
Not seeing the execute permission?
- Make sure you used sudo with the chmod command. Without it, you don't have write permission on /usr/local/bin/.
- Verify the filename matches exactly: health-check.sh.
- If the file doesn't exist, re-run the sudo vi command above and make sure you saved with :wq.
Still stuck? Help me troubleshoot why my health-check.sh script isn't executable on Rocky Linux.
Run and verify the script
Before you run the script, think about what you expect to appear in the log. Your NGINX container is running, your disk isn't full, and your VM has available memory.
- What do you think the output will say for the container status line? Will it be [OK] or [ALERT]?
- Run the health check script:
sudo /usr/local/bin/health-check.sh
- Check the log output by running:
cat /var/log/server-health.log
You should see a timestamped report with [OK] NGINX container is running, a disk usage percentage, and available memory in MB.
- Run the script a second time to confirm entries append correctly:
sudo /usr/local/bin/health-check.sh && cat /var/log/server-health.log
You should now see two separate timestamped entries in the log, proving the script appends rather than overwrites.
Seeing an empty log or permission denied?
- If the log file is empty, check your script for typos in the >> redirect operators. A single > overwrites instead of appending.
- If you see permission denied, make sure you ran the script with sudo. Writing to /var/log/ requires root privileges.
- If the container status shows [ALERT], verify your container is running with docker ps.
Still stuck? Help me debug why my health-check.sh script isn't producing the expected log output.
✔️ Awesome, I've got everything!
Great. Make sure you've saved health-check.sh and confirmed both log entries appear in /var/log/server-health.log.
ⓧ I'd like to double check the full code
Here is the complete health-check.sh script. Compare it character-for-character with what you saved:
#!/bin/bash
LOG_FILE="/var/log/server-health.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "===== Health Check: $TIMESTAMP =====" >> "$LOG_FILE"
# Check container status
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' nginx-web 2>/dev/null)
if [ "$CONTAINER_STATUS" == "running" ]; then
echo "[OK] NGINX container is running" >> "$LOG_FILE"
else
echo "[ALERT] NGINX container is NOT running (status: $CONTAINER_STATUS)" >> "$LOG_FILE"
fi
# Disk usage
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')
echo "[INFO] Disk usage: $DISK_USAGE" >> "$LOG_FILE"
# Memory usage
MEM_AVAILABLE=$(free -m | awk 'NR==2 {print $7}')
echo "[INFO] Available memory: ${MEM_AVAILABLE}MB" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"
Schedule with cron
Running the script manually is useful for testing, but in production you want automated checks. Cron is a time-based job scheduler built into Linux that runs commands on a repeating schedule.
- Open the root crontab for editing by running:
sudo crontab -e
What is a crontab?
A crontab is a per-user file that lists scheduled jobs. Using sudo crontab -e edits the root user's crontab, which is required because the script writes to /var/log/.
The editor that opens is vi. You'll add one line and save.
- Press i to enter insert mode, then add this line:
*/5 * * * * /usr/local/bin/health-check.sh
What does */5 * * * * mean?
Cron uses five fields: minute, hour, day-of-month, month, day-of-week. */5 in the minute field means "every 5 minutes". The asterisks in the remaining fields mean "every" (any hour, any day, any month, any weekday).
- Press Esc, then type :wq and press Enter to save and exit.
- Verify the cron job is scheduled by running:
sudo crontab -l
You should see */5 * * * * /usr/local/bin/health-check.sh listed in the output.
Crontab appears empty?
- Make sure you used sudo crontab -e (root's crontab), not just crontab -e (your user's crontab).
- If the line didn't save, re-open with sudo crontab -e and check that you pressed :wq to write and quit, not just :q.
Still stuck? Help me set up a cron job on Rocky Linux 9 that runs my health-check script every 5 minutes.
Secret mission
Container Down Alert System
Production engineers don't just monitor. They alert. In this mission, you'll extend your health-check script into an incident response tool that detects container failures, writes detailed incident reports, and attempts automatic recovery.
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. The B2ats v2 VM is covered under Azure's free tier (750 hours/month for 12 months with a new account), but if your free tier has expired the VM costs approximately $0.02/hr.
Cost warning
If your Azure free account's 12-month benefit has expired, the running VM incurs charges at approximately $0.02/hr. Deallocate or delete the resource group when you are done to avoid unexpected costs.
Resources you used:
- Azure resource group rg-linux-lab (contains the VM, OS disk, NIC, network security group, and public IP).
- Azure CLI installed on your local Windows machine.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with another project on this VM.
- Deallocate the VM when you are not using it to preserve your free-tier hours. Run this from your local PowerShell:
- Azure CLI stays installed on your local machine for future Azure projects.
az vm deallocate --resource-group rg-linux-lab --name rocky-server
To restart it later, run:
az vm start --resource-group rg-linux-lab --name rocky-server
Pause - I'll come back to this later
Shut down the VM to stop billing while preserving your disk, configuration, and files. You can restart it later without losing anything.
- Deallocate the VM by running this in your local PowerShell:
az vm deallocate --resource-group rg-linux-lab --name rocky-server
While deallocated, compute charges stop. You still pay a small amount for disk storage (covered under free tier for new accounts).
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Delete the entire resource group (this removes the VM, disk, NIC, NSG, and public IP in one command). Run this in your local PowerShell:
az group delete --name rg-linux-lab --yes --no-wait
- Optionally, uninstall the Azure CLI from your local machine by running:
winget uninstall --id Microsoft.AzureCLI
- Sign out of Azure CLI before uninstalling if you want to clear cached credentials:
az logout
Nice Work!
Nice Work!
You just provisioned, hardened, and automated a production-ready Rocky Linux 9 server on Azure. You can now walk into an interview and demonstrate the exact skills a Linux Cloud Engineer is expected to have on day one.
You learned how to:
- Provision a Rocky Linux 9 VM on Azure using the CLI, configuring resource groups, networking, and SSH key authentication from the command line.
- Harden the server with SSH key-only authentication, disabled root login, and a firewalld configuration that permits only SSH and HTTP traffic.
- Deploy a containerized NGINX web server managed by a custom systemd unit file that auto-starts on boot and restarts on failure.
- Build a bash health monitoring script that checks container state, disk, and memory usage, with automated scheduling via cron.
- Secret Mission: Created an incident detection and alerting system that detects container state transitions and writes detailed incident reports with automatic recovery attempts.
Ready to quiz yourself?