AlmaLinux 9 on Hostinger KVM2. Every command in the order it appears on screen. Substitute your own username and domain where indicated.
Watch on YouTubeDownload PuTTY and PuTTYgen from putty.org. Open PuTTYgen. Key type: EdDSA, confirm the dropdown shows Ed25519. Click Generate and wiggle the mouse over the blank area - the bar won't move otherwise.
Use the text in the top box ("Public key for pasting into OpenSSH authorized_keys"). Do NOT use the "Save public key" button - that format gets rejected by Hostinger and SSH.
Save private key as a .ppk file. Paste the public key text into Hostinger dashboard > SSH keys.
Host = server IP, port 22. Connection > SSH > Auth > Credentials: browse to your .ppk. Connection > Data: auto-login username root. Session: name it "teachvps root", click Save, then double-click to open.
First login shows a security alert about the host key not being cached. Accept it. If you see this warning again later for a server you already trust, stop and investigate.
cat /root/.ssh/authorized_keys
Expect: exactly one key - the one you just created. Get in the habit of checking this on every login to a new box.
dnf update -y
If the update installs a new kernel, reboot is required.
reboot
PuTTY will drop. Wait 30-60 seconds, reopen the saved session. No host key warning this time.
uname -r
Expect: the new kernel version shown during the update. Proof the kernel update took.
Run all of these as root. Replace "tony" with your chosen username throughout.
useradd tony
passwd tony
usermod -aG wheel tony
mkdir -p /home/tony/.ssh
cp /root/.ssh/authorized_keys /home/tony/.ssh/authorized_keys
chown -R tony:tony /home/tony/.ssh
chmod 700 /home/tony/.ssh
chmod 600 /home/tony/.ssh/authorized_keys
Open a second PuTTY session as the new user before continuing. In PuTTY: load the root session, change Data > auto-login username to your new user, rename the session (e.g. "teachvps tony"), save, and open it. Never close a working door until the new one is proven.
whoami
sudo whoami
Expect: first returns your username, second returns root after the sudo lecture and your password. sudo remembers your password for ~5 minutes per session.
Run as your admin user (with sudo) from here forward. Not as root.
sudo nano /etc/ssh/sshd_config
Find PasswordAuthentication (Ctrl+W to search). Uncomment the line and change the value to no. Find PermitRootLogin and change the value to prohibit-password. Save with Ctrl+X, Y, Enter.
Cloud images drop override files in sshd_config.d. The first value sshd sees wins - meaning your edit above may already be overridden.
sudo grep -r "PasswordAuthentication\|PermitRootLogin" /etc/ssh/sshd_config.d/
Expect: /etc/ssh/sshd_config.d/50-cloud-init.conf:PasswordAuthentication yes
sudo nano /etc/ssh/sshd_config.d/50-cloud-init.conf
Change PasswordAuthentication yes to PasswordAuthentication no. Save and exit.
sudo systemctl restart sshd
sudo sshd -T | grep -E "permitrootlogin|passwordauthentication"
Expect: permitrootlogin without-password (this is the same as prohibit-password) and passwordauthentication no. sshd -T shows what the server actually resolved, not what a file claims.
Open a third PuTTY session to verify you did not lock yourself out before closing anything.
Do this step at least 30 minutes before creating your virtual server in Virtualmin. DNS must be live before the SSL certificate request fires.
Cloudflare DNS > Records. Create two A records: one for yourdomain.com and one for www.yourdomain.com, both pointing to your server IP. Proxy status: grey cloud (off). If yours is orange, click Edit and turn the proxy off.
Leaving the proxy orange at this stage will cause the Let's Encrypt HTTP-01 challenge to fail and you will not get an SSL certificate during virtual server creation. Video 2 covers switching to DNS-01 so you can safely turn it orange.
firewalld must be installed before Virtualmin. If Virtualmin is installed first and firewalld is absent, Virtualmin uses iptables instead. Installing firewalld after the fact gives you two active firewalls. One firewall, installed first, is the correct setup.
sudo firewall-cmd --list-all
Expect on a fresh Hostinger AlmaLinux 9 image: "sudo: firewall-cmd: command not found" - firewalld is not installed by default.
sudo dnf install firewalld -y
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-port=10000/tcp
sudo firewall-cmd --permanent --remove-service=cockpit
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Expect after reload: services include http, https, ssh, dhcpv6-client. Cockpit is gone. Ports: 10000/tcp. dhcpv6-client is harmless, leave it.
wget https://software.virtualmin.com/gpl/scripts/virtualmin-install.sh
sudo sh virtualmin-install.sh
At the Continue? prompt: confirm your OS is on the supported list, note the LAMP stack being installed (Linux, Apache, MariaDB, PHP), and note the log path shown on screen. Hit y. This takes 10-15 minutes.
Expect at completion: 21 steps with checkmarks. Step 7 shows "Configuring firewalld" and step 20 shows "Configuring Fail2ban Firewalld" - these confirm Virtualmin detected firewalld and integrated with it. Step 12 may show a yellow warning about quotas requiring a reboot - that comes next.
sudo firewall-cmd --list-all
Expect: Virtualmin has added dns, ftp, imap, imaps, pop3, pop3s, smtp, smtps, and ports for FTP, Virtualmin panel (10000-10100), Usermin (20000), and passive FTP (49152-65535). All in the same firewall.
sudo systemctl status iptables 2>&1 | head -3
Expect: "Unit iptables.service could not be found." One firewall. This is correct.
sudo reboot
Clears the quota warning. Reconnect as your admin user (not root) after 30-60 seconds.
Browser: https://YOUR_SERVER_IP:10000 - you will get a self-signed cert warning, that is expected. Click Advanced and proceed. Log in as your admin user (not root).
The wizard has 7 pages. Recommended answers:
1. Mail domain lookup server - Yes (only 70MB, worth it on any box with 1GB+ RAM).
2. ClamAV virus scanning - Yes if you have 4GB+ RAM (uses up to 2GB constantly). No if less than 4GB or if you are not running email.
3. SpamAssassin - Yes if 8GB+ and running mail. Skip on 4GB boxes running a database or busy site, or if using daemon mode which removes per-domain spam settings.
4. MariaDB - Yes. PostgreSQL - No (unless a specific app requires it).
5. MariaDB root password - Set your own. Write it in your password manager immediately.
6. DNS configuration - Leave hostname as-is. Check "Skip check for resolvability". Leave additional nameservers empty. Cloudflare handles your DNS, not Virtualmin.
7. System email recipient - Set this to a real address you read. This is where cert renewal notices, cron errors, and alerts land.
Verify Apache is running before creating the virtual server. A crashed Apache means the Let's Encrypt HTTP-01 challenge has no responder and SSL will fail.
sudo systemctl status httpd | head -5
Expect: Active: active (running). Do not proceed if Apache is not running.
Virtualmin > Create Virtual Server. Domain: yourdomain.com. Set an administration password. Leave everything else default - all enabled features checked. Click Create Server.
Watch the creation log. Near the end look for: "Setting up initial SSL certificate .. successfully requested SSL certificate for yourdomain.com, www.yourdomain.com" - this fires because DNS was already live before you created the server.
Browser: https://yourdomain.com - confirm valid padlock (Let's Encrypt, not self-signed).
The index.php status page file is available at ownyourserver.com/resources.html. Download it to your desktop.
sudo rm /home/yourdomain/public_html/index.html
Then in Virtualmin: select your domain > File Manager > public_html > File > Upload to current directory. Upload index.php. Hard reload the browser (Ctrl+Shift+R).
Expect: dark terminal status page showing hostname, OS, kernel, uptime, load, memory, disk, PHP version, and server time. Ownership of index.php should be your domain user, not root.
sudo ls -la /home/yourdomain/public_html/
Expect: index.php owned by yourdomain:yourdomain.