A cloud server security baseline: nine hardening steps before launch — SSH keys, least-privilege security groups, MFA, patching, tested restores.
The path into a cloud server is usually boring: find the open port 22 → brute force or credential-stuff a weak password → get root → drop a cron job, start mining, or move sideways. A cloud server security baseline exists to make every link in that chain expensive: nothing worth scanning, no way in through passwords, no root to take, and every action left a trace.
The test is not "did we do it" but "can someone else verify it". Pair every control with a command you can run yourself — a baseline is not a document, it is a check you can repeat every month.
The 9 hardening items at a glance (use it as a checklist)
Read as a whole, these nine items do three things: shrink the surface (SSH, firewall), shrink the blast radius (non-root, MFA, least privilege), and make sure you can recover (backups, logs, credential rotation). The table below is the minimum bar, not the target — hit it first, then get stricter. Where you need provider-side controls (WAF, DDoS protection, bastion access), fill the gaps against cloud security products.
| # | Item | Minimum bar before launch | How to verify |
|---|---|---|---|
| 1 | SSH login | No root login, no passwords, keys only | sshd -T reports no for both |
| 2 | Firewall / security group | Admin ports limited to source IPs | No 0.0.0.0/0 rule on 22/3306/6379 |
| 3 | Accounts and privileges | One IAM identity per person | IAM user list maps to real people |
| 4 | MFA | Enforced on the main account and privileged users | Console shows it enabled |
| 5 | Patching | OS monthly, app dependencies quarterly | apt list --upgradable is empty |
| 6 | Backup and restore | Daily snapshots plus a quarterly restore drill | One successful restore from snapshot |
| 7 | Encryption | Disk encryption at rest, TLS 1.2+ everywhere | curl -I https:// returns 200 |
| 8 | Logs and alerting | At least 90 days retained, alert on new SSH keys | lastb -ai is readable and populated |
| 9 | Secret management | No credentials in git, stored and rotatable | Repo secret scan returns clean |
Order matters: SSH key login first, firewall rules second. Doing it the other way round (closing ports before pushing the key) locks you out of your own server, and the only way back is the provider rescue console — usually a human ticket.
Item 1: SSH — turn off password login, keep keys only
SSH brute force is still the number one way into a cloud host. The first control is simple: make the password path not exist.
- Generate a key for yourself or the
deployuser:ssh-keygen -t ed25519 -C "you@laptop", then push the public key:cat ~/.ssh/id_ed25519.pub | ssh deploy@SERVER "umask 077; mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys" - Prove the login works in a fresh terminal before touching the config, and keep the current session open.
- Back up the config:
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak. Remember that sshd honours the first occurrence of most keywords, so appending a line at the end of the file does nothing — edit the existing line. - Disable root login:
sudo sed -i "s/^#*PermitRootLogin.*/PermitRootLogin no/" /etc/ssh/sshd_config - Disable password login:
sudo sed -i "s/^#*PasswordAuthentication.*/PasswordAuthentication no/" /etc/ssh/sshd_config. On older distributions the keyword is calledChallengeResponseAuthentication— handle that one too. - Validate the syntax before reloading:
sudo sshd -t && sudo systemctl reload ssh(the service is namedsshdon RHEL-family systems). - Confirm the effective values:
sudo sshd -T | grep -Ei "permitrootlogin|passwordauthentication"should printPermitRootLogin noandPasswordAuthentication no.
Then rate-limit attempts with fail2ban: sudo apt install -y fail2ban && sudo systemctl enable --now fail2ban, and inspect bans with sudo fail2ban-client status sshd.
On "moving SSH to port 2222": it does not make you safer. Public scanners sweep 1–65535, so a custom port only quiets the default log. The real gain comes from source restriction and key-only login.
Item 2: Firewall and security group — deny by default
The network goal is deny by default. The cloud security group is the first wall (traffic is dropped before it reaches the host); ufw or firewalld on the host is the second. Keeping both costs almost nothing.
- Security group: open 80/443 to
0.0.0.0/0only; open 22 to your office egress IP; never give 3306 / 5432 / 6379 / 27017 / 9200 a0.0.0.0/0rule - Debian/Ubuntu host firewall:
sudo ufw default deny incoming→sudo ufw allow 22/tcp→sudo ufw allow 80,443/tcp→sudo ufw enable - RHEL-family host firewall:
sudo firewall-cmd --permanent --add-service=http --add-service=https, then add a rich rule allowing port 22 from a specific source, and finish withsudo firewall-cmd --reload - Databases listen on loopback or the private network only: MySQL with
bind-address=127.0.0.1; Redis withbind 127.0.0.1 -::1andprotected-mode yes, reached remotely only over the VPC private network plus a security group
Run ufw allow 22/tcp before ufw enable. (ufw allow OpenSSH works too — it is the profile alias on Ubuntu.) Reversing the order drops the current SSH session immediately.
Check two places: local listeners with ss -tulpn | grep LISTEN, and external exposure with nmap -Pn your.server.ip from a different machine. Both should list only the ports you intended to publish.
Item 3: Accounts — one identity per person, no shared root
Treat root as an account belonging to the system, not to people. The target state: every action maps to a specific identity, and one person can be revoked without touching anyone else.
- Console side: create IAM users (or the equivalent sub-users) with least-privilege policies scoped to read-only monitoring or to a single instance; delete long-lived access keys on the main account and let CI and operators use roles with temporary credentials valid for 15–60 minutes
- Host side: create a
deployuser for routine work and grant only the commands it needs viasudo visudo, instead of a blanketNOPASSWD: ALL - One SSH key per person — never copy the same
id_rsaaround the team. Offboarding is then a single deleted line inauthorized_keys - Add a passphrase to the private key and keep it in ssh-agent:
ssh-keygen -p -f ~/.ssh/id_ed25519pluseval "$(ssh-agent -s)"
Precise sudo grants (in /etc/sudoers.d/deploy): deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp.service. Open exactly the command you need, not root on the whole box.
Item 4: MFA — add a real check before destructive actions
Keys and passwords get copied and phished. MFA is the only control that breaks the assumption "a leaked credential works as-is." It does not stop an intrusion; it invalidates a stolen secret.
- Enable MFA on the main account, and do not rely on SMS alone: SIM swaps and SMS floods are real techniques. Prefer TOTP (Google Authenticator, 1Password, Bitwarden and similar)
- Enforce by privilege tier: users who can change security groups or delete resources must have it; read-only accounts can wait
- Store recovery codes offline in a password manager or a sealed printout — not on the same machine, not in the team chat
- Console login and SSH login are different things: the former uses the console MFA, the latter needs a PAM module such as
pam_google_authenticatoror an sshdAuthenticationMethodspolicy
Do not ship a theatrical MFA policy: someone on the team must be able to run the reset flow for a colleague, and that person needs MFA as well. Otherwise one mistyped second factor becomes an outage.
Enforce it in policy rather than by convention: add "Bool": { "aws:MultiFactorAuthPresent": "true" } to the Condition block of the policy so calls without MFA are denied outright.
Item 5: Patching — schedule updates instead of waiting for incidents
Hardening is not a one-off. The rule is short: patch what can be patched today, schedule the rest (releases, reboots) into a fixed window.
- OS level: on Debian/Ubuntu run
sudo apt update && sudo apt list --upgradable, then installunattended-upgradesand setAPT::Periodic::Update-Package-Lists "1";andAPT::Periodic::Unattended-Upgrade "1";in/etc/apt/apt.conf.d/20auto-upgrades - RHEL family: update manually with
dnf update -y, automate withdnf-automaticviasudo systemctl enable --now dnf-automatic.timer - Kernel and app dependencies: kernel updates need a reboot to take effect (check the version with
uname -r, reboot in the window); scan dependencies separately withnpm audit --production,pip-audit, andgovulncheck ./... - Container images: rebuild periodically with
docker build --pull. Base image vulnerabilities do not disappear because your application code did not change
Distributions expire: standard maintenance on mainstream releases runs roughly 5 years, after which security updates stop (CentOS 7 ended in June 2024, and Ubuntu 20.04 LTS standard maintenance ended in May 2025). A machine still on those versions needs an upgrade or a commercial extended-support contract.
For a one-off full audit: sudo apt install -y lynis && sudo lynis audit system. Keep the report as evidence, and diff the next one against it.
Item 6: Backups — a snapshot you never restored is a hypothesis
A backup is worth nothing until you restore it. An untested backup is a hypothesis.
- Automatic snapshots: once or twice a day, keeping 7–14 days; take a manual snapshot with a label before every release or schema change
- A snapshot is crash-consistent, not application-consistent: InnoDB rolls back on recovery, but logical transactions can be lost. Export databases too:
mysqldump --single-transaction --quick -u root -p dbname > /backup/db-$(date +%F).sql.gz(for InnoDB use--single-transactioninstead of--lock-tables) - Keep a copy off-site: replicate snapshots across regions or sync to object storage, and encrypt the backups themselves — they usually contain a full copy of user data and are the most overlooked exposure
- Restore drill: each quarter boot a new instance from a snapshot on an isolated network, verify row counts and the newest timestamps, record the real RTO, then destroy the test instance
Put drill results in the runbook. "RTO 2 hours" is a promise; "measured August 2026: snapshot to readable data in 38 minutes" is a number.
Item 7: Encryption — disks at rest, TLS in transit
Encryption has one job: when the disk is stolen, the backup is downloaded, or traffic is read in the middle, the data stays unreadable.
- At rest: cloud disks and snapshots normally enable provider-side encryption at no extra charge; use a customer-managed key (CMK) for backup buckets, object storage, and database snapshots so keys can be revoked when people leave. CMKs are usually billed per key and per call, so check live pricing on the provider site
- In transit: HTTPS everywhere, keep only
ssl_protocols TLSv1.2 TLSv1.3;in Nginx, redirect 80 to 443 and set HSTS; issue and renew certificates withsudo certbot --nginx -d example.com - Encrypt the internal path too: talk over private IPs inside the VPC, force TLS with certificate verification for database connections, and never expose Redis or MySQL ports outward
- SSH details: set
ForwardAgent no, disable unnecessary forwarding, and centralise private keys in ssh-agent instead of scattering copies across machines
Transport encryption is not just the public API. Upload endpoints, webhook callbacks, pre-signed object storage URLs, and third-party SDK certificate-verification switches all need checking — they are often left off in test environments.
Certificate expiry incidents are rarely caused by weak crypto. They are caused by renewal being a human responsibility. Automate it.
Item 8: Logging and alerting — 90 days of trail, alerts on new keys
In an incident there is exactly one dividing line: can you find out what happened three days ago, right now.
- Retain system logs: set
rotate 14withdailyin/etc/logrotate.d/rsyslog, and checkjournalctl --disk-usageto confirm the on-disk volume stays under control - Turn on auditd for sensitive file changes:
sudo systemctl enable --now auditd, with rules in/etc/audit/rules.d/audit.rules:
- Rule lines:
-w /etc/passwd -p wa -k identity,-w /etc/sudoers -p wa -k sudo,-w /etc/ssh/sshd_config -p wa -k sshd_config - Load them:
sudo augenrules --load(on RHEL family also runsudo systemctl restart auditd) - Investigate later with
sudo ausearch -k identity -ts recent
- Cloud-side auditing: enable object storage access logs and the provider operation audit service (CloudTrail and equivalents), written by a separate account with a retention policy
- Keep only three high-signal alerts: a new SSH public key, a spike in failed logins (brute force from an unfamiliar range), and admin actions outside working hours
Set a floor for retention: 30–90 days online, exported to object storage for a year. For quick triage, last -ai shows successful logins and lastb -ai shows failed ones.
Default fail2ban thresholds can ban an entire office egress range during a network blip. Watch sudo fail2ban-client status sshd for a week before tightening the ban time.
Item 9: Secret management — no credential ever enters the repository
The most common credential leak is not a breach. It is a key committed to git, baked into an image layer, or pasted into a ticket and a chat window.
- No secrets in code: add
.envto.gitignore. If it was already committed, treat it as compromised —git rm --cached .envonly stops future tracking, history needsgit filter-repo, and the credential must be rotated at the same time - Scan before committing:
gitleaks detectortrufflehog git ., wired into pre-commit or CI - Inject at runtime: supply secrets as environment variables from CI/CD, or keep them in a managed secret service (Secrets Manager, KMS and similar) and fetch them at startup. Do not put them in a Dockerfile
ENV— that bakes them into the image - File permissions:
chmod 600 .env, create sensitive files underumask 077, run the application as its own system user, and usechown root:appgrouppluschmod 640where a group needs read access
Command-line arguments show up in ps output. With mysql -uroot -pPASSWORD, any user on that machine can read the password via ps aux. Use the interactive prompt, or mysql --defaults-extra-file=/root/.my.cnf. Also check shell history for export SECRET=....
Give rotation a rhythm: update the database password with ALTER USER together with the application release, rotate third-party API keys quarterly, and record purpose, owner, creation date and expiry for every key. A key with no expiry date is a permanent key.
A key just leaked: the 15-minute response checklist
Assume an SSH private key or a cloud access key just leaked. The first move is revocation — not a password change, and definitely not two days of watching.
- Revoke (0–5 min): disable or delete the exposed access key in the IAM console; remove the matching public key from
authorized_keyson every server (make sure you still have another way in first, or you will lock yourself out) - Preserve evidence (5–15 min): snapshot the affected instance immediately and export
sudo last -ai,lastb -ai,sudo journalctl -u ssh --since "-24h", the currentss -tulpnoutput, plus cron entries and custom systemd units (/etc/cron*,/etc/systemd/system/) - Hunt (15–60 min): new system users, diffs in
authorized_keys,crontab -l, unexpected listening ports, unexpected outbound connections (ss -tunp), and suspicious public key comments - Rotate (in parallel): everything reachable from that machine — database passwords, object storage credentials, third-party API keys, CI/CD secrets, registry tokens. One leak usually means the whole credential set changes
- Rebuild rather than clean: if root access is confirmed, cleaning in place is not enough, because persistence may sit where you did not look. Rebuild from a trusted image or a known-good snapshot and migrate only data you have reviewed
- Write it up: a timeline (when detected, which credentials were affected, whether they were used), notify the parties involved based on impact, and turn the finding into an alert rule
If the credential was actually used before revocation, handle it as a data breach: work out what data was accessed and whether disclosure is required, instead of closing the ticket as a configuration mistake.
How to verify: a 15-minute self-check
A baseline should be verifiable in 15 minutes. Open another terminal and run these in order:
- Effective SSH config:
sudo sshd -T | grep -Ei "permitrootlogin|passwordauthentication"— expectnofor both - External exposure, from another machine:
nmap -Pn your.server.ip— only 22/80/443 plus anything you deliberately opened - Local listeners:
ss -tulpn | grep LISTEN— databases and caches should bind to127.0.0.1or a private address - Firewall rules:
sudo ufw status verbose(orsudo firewall-cmd --list-all) — default policy should deny or allow-list only - Pending updates:
sudo apt list --upgradable(Debian family) /dnf check-update(RHEL family) - Failed logins:
lastb -ai | head -20— unfamiliar ranges deserve a follow-up - SUID files and scheduled tasks:
sudo find / -xdev -perm -4000 -type f 2>/dev/null,crontab -l,ls -la /etc/cron.d/ - Repository secret scan:
git ls-files | xargs grep -nEi "akia|secret|password"— any hit is treated as leaked
Put these eight into a monthly routine or a CI baseline job. Automated hardening is a baseline; hardening done once is just a config change.
Next step: run the eight checks against one production machine and split the findings into "fixable in five minutes" and "needs a scheduled window". Clear the first column today, and put the second into the release schedule with an owner and a date. The other half of the risk sits at the account level — accounts of unclear provenance, shared payment instruments, guilt-by-association suspensions — and it is not covered by these nine items: five suspension causes and how to prevent them.
This is a provider-neutral minimum set. Your vendor documentation may be stricter (forcing IMDSv2, default-deny inbound rules, mandatory log retention), so follow the live official documentation for the platform you actually run.