An interactive Bash provisioning script for Ubuntu 24.04 LTS and Ubuntu 26.04 LTS Server (minimized or standard) to quickly provision a hardened server and install any of the following services if desired:
- Auditd*
- Fail2Ban*
- Forgejo
- Firewall*
- Grafana
- IP Blocklists*
- CIS Level 1 hardening*
- Kernel module blocklists*
- Monit*
- Mosquitto
- MySQL or MariaDB
- Nginx
- OpenSSH*
- Postfix
- PostgreSQL
- Suricata IDS*
- Valkey
- Wazuh Agent*
- Webmin
Every component is optional — you choose what gets installed and configured at runtime via interactive prompts. The script is re-entrant, but best run on a fresh install. The components marked * are recommended.
The script walks you through each step with a y/n prompt. If a ubuntu_provision.conf file is provided, those values are used automatically and the corresponding prompts are skipped (See Configuration File section below).
Provision configuration files allow the fast and automated setup of different server configurations, for example for development, testing, production.
Below is a typical interactive session showing all prompts for a full installation. Values shown are examples — your defaults will vary based on detected system state and previously installed services.
Configure installation options:
Would you like to add a new sudo user? (y/n) : n
Use current user 'example'? (y/n) : y
Would you like to configure LVM disks? (y/n) : y
LVM detected, but no free space remains in Volume Group. Skipping.
Would you like to configure swap space? (y/n) : y
Active swap detected. No changes.
Would you like to tune the system? (y/n) : y
Hour to run apt-get update (0-23) : 10
Hour to run apt-get upgrade (0-23) : 11
Would you like to uninstall packages? (y/n) : y
Would you like to update packages? (y/n) : y
Would you like to install new packages? (y/n) : y
Would you like to configure ufw? (y/n) : y
Would you like to configure ssh? (y/n) : y
Would you like to install fonts? (y/n) : y
Install Microsoft core fonts? (y/n) : y
Would you like to install weasyprint? (y/n) : y
Would you like to install imagemagick? (y/n) : y
Would you like to install Python 3.14? (y/n) : y
Would you like to install Pypy 3.11? (y/n) : n
Would you like to install nginx? (y/n) : y
nginx worker processes? : 2
Would you like to install valkey? (y/n) : y
Would you like to install MySQL? (y/n) : n
Would you like to install MariaDB? (y/n) : y
MariaDB root password : ********
InnoDB buffer pool chunk size (MB) : 128
InnoDB buffer pool instances (1-64) : 1
Max connections : 100
InnoDB log buffer size (MB) : 64
Binlog cache size (MB) : 16
Join buffer size (KB) : 512
Sort buffer size (KB) : 512
Read buffer size (KB) : 128
Read rnd buffer size (KB) : 1024
Would you like to install PostgreSQL 18? (y/n) : y
PostgreSQL superuser (postgres) password : ********
Max connections : 100
Available memory: 3868MB, CPU cores: 2
Suggested: shared_buffers=967MB, work_mem=9MB, effective_cache_size=2320MB
Shared buffers (MB) : 837
Work mem (MB) : 8
Effective cache size (MB) : 2010
Max worker processes (= cores) : 2
Max parallel workers (= cores) : 2
Max parallel workers per gather (= cores/2) : 1
Effective IO concurrency (SSD=100+, HDD=1) : 100
Would you like to install Mosquitto? (y/n) : y
Would you like to install Postfix (relay-only SMTP)? (y/n) : y
SMTP relay host (mailserver address) : mail.example.com
SMTP relay port : 587
SMTP relay username : [email protected]
SMTP relay password : ********
Mail domain (used in From address) : example.com
From address (e.g. [email protected]) : [email protected]
Forward local root mail to (root alias) : [email protected]
Would you like to install Monit? (y/n) : y
Send Monit alerts via Postfix? (y/n) : y
Alert sender address : [email protected]
Monit admin username : admin
Monit admin password : ********
Alert recipient address : [email protected]
Would you like to install Webmin? (y/n) : n
Would you like to install Grafana? (y/n) : y
Send Grafana alerts via Postfix? (y/n) : y
From address : [email protected]
From name : Grafana
Would you like to install Forgejo? (y/n) : y
Port 3000 is in use by Grafana. Enter Forgejo port : 3030
Enter Forgejo domain or ip : 172.16.71.131
Send Forgejo mail via Postfix? (y/n) : y
From address : [email protected]
Would you like to disable TCP transmit offloading? (y/n) : n
Would you like to install Fail2Ban? (y/n) : y
Would you like to install auditd? (y/n) : y
Would you like to install IP blocklist? (y/n) : y
Would you like to install Suricata IDS? (y/n) : y
Would you like to install Wazuh Agent? (y/n) : y
Enter IP or Hostname of Wazuh Manager : 172.16.71.2
Would you like to configure AppArmor? (y/n) : y
AppArmor status: installed=y, enabled=y, mode=enforce
Enable AppArmor? (y/n) : y
Set profiles to enforce mode? (y/n) : y
Blacklist unused kernel modules using modulejail? (y/n): y
Would you like to apply CIS Level 1 hardening? (y/n) : y
Would you like to create and run the Health Check script? (y/n) : y
Configuration complete.
To start, download the repository. If you have git available:
git clone https://github.com/xlvisuals/provision-ubuntu-server.git
cd provision-ubuntu-server
Or without git and unzip, e.g. on a fresh minimized Ubuntu Server installation:
wget https://github.com/xlvisuals/provision-ubuntu-server/archive/refs/heads/main.zip -O provision-ubuntu-server.zip
python3 -c "import zipfile; zipfile.ZipFile('provision-ubuntu-server.zip').extractall('.')"
cd provision-ubuntu-server-main
Then run ubuntu_provision.sh
sudo bash ubuntu_provision.shOr with a configuration file to skip interactive prompts:
sudo bash ubuntu_provision.sh ubuntu_provision.confA timestamped log is written automatically to /var/log/ubuntu_provision_<date>.log.
After the script completes: test SSH login in a new terminal window before closing the current session, then reboot to apply all changes.
| Step | Description |
|---|---|
| Backup | Full config snapshot taken before prompting and again before applying changes, via ubuntu_backup_config.sh |
| Timezone | Set to UTC |
| LVM | Optionally resize LVM root volume to a target size or 100% of the volume group |
| Swap | Creates a configurable swap file if none exists |
| User | Creates a new sudo user or configures an existing one; sets up SSH key access |
| Uninstall | Removes unneeded packages (modemmanager, snapd, bluetooth, apport, etc.) |
| Updates | Full system upgrade via apt-get full-upgrade |
| UFW Firewall | Default deny inbound, allow outbound; opens ports 22, 80, 443 as appropriate |
| SSH Hardening | Key-only authentication, no root login, restricted AllowUsers, MaxAuthTries 3 |
| Sudo | 60-minute timestamp_timeout; NOPASSWD for apt-get, systemctl, reboot, and the health check script |
| Update Timers | Unattended-upgrades scheduled at prompted UTC hours (apt update runs twice daily, 12 hours apart) |
| File Limits | nofile and nproc raised via a drop-in in limits.d |
| Kernel Tuning | sysctl hardening — swap tuning, ICMP redirect blocking, IP spoofing prevention, kernel pointer/dmesg restrictions |
| Shared Memory | /run/shm mounted nosuid,nodev,noexec |
| TCP TX Offload | Optionally disables TCP transmit offloading on the primary interface via a systemd oneshot service |
| AppArmor | Optionally enables AppArmor, enforces available profiles, and sets installed services without profiles to complain mode |
| Ubuntu Pro | If not attached, disables Ubuntu Pro background services to reduce noise |
| CIS Level 1 Hardening | Applies CIS Level 1 server benchmark controls: password policy (complexity, max age, history, lockout), PAM hardening (nullok removal, pam_faillock), account inactivity lock, umask 027, su restricted to wheel group, system account shell lockdown, cron/at access control, SSH drop-in (ciphers, MACs, KEX, banner, HostbasedAuthentication no), sudo logfile, core dump disabling, additional sysctl hardening (ASLR, syncookies, martian logging, ICMP redirect, IPv6), kernel module blacklist (cramfs, hfs, hfsplus, jffs2, udf, usb-storage), /dev/shm mount options, journald→rsyslog forwarding, NTP configuration, session timeout (TMOUT=900), file integrity monitoring (AIDE, or Wazuh FIM if Wazuh is installed), and Postfix loopback-only binding. Controlled by CONFIGURE_CIS_HARDENING. |
Web Stack
| Package | Default Port | Notes |
|---|---|---|
| Nginx | 80, 443 | |
| MySQL Server | 3306 | Tunable InnoDB parameters; auto-calculated from RAM if left blank |
| MariaDB Server | 3306 | Alternative to MySQL. Tunable InnoDB parameters; auto-calculated from RAM if left blank |
| PostgreSQL 18 | 5432 | Tunable memory and parallelism parameters; auto-calculated from RAM/CPU if left blank |
| Valkey | 6379 | Redis-compatible cache |
| Mosquitto | 1883 | MQTT broker |
| Package | Notes |
|---|---|
| Postfix | Relay-only — accepts local mail and forwards to an external SMTP provider. Monit, Grafana, and Forgejo can each be configured to route through Postfix instead of providing individual SMTP credentials |
Management & Monitoring
| Package | Default Port | Notes |
|---|---|---|
| Webmin | 10000 | Installed via official webmin-setup-repo.sh |
| Monit | 2812 | |
| Grafana | 3000 | |
| Forgejo | 3000 (3030 if Grafana also installed) | Self-hosted Git |
Runtimes
| Package | Notes |
|---|---|
| Python 3.14 | Via deadsnakes PPA on Ubuntu 24.04; pre-installed on Ubuntu 26.04 |
| PyPy 3.11 | Installed to /opt, symlinked into /usr/local/bin; latest release fetched dynamically with hardcoded fallback |
| Weasyprint | PDF generation |
| ImageMagick | Image processing |
| Fonts | Liberation, FreeFonts, Microsoft core fonts (EULA pre-accepted) |
Security
| Package | Notes |
|---|---|
| Fail2Ban | Bans IPs after repeated failed SSH attempts |
| auditd | Kernel-level audit logging |
| Suricata IDS | Network intrusion detection; community-id enabled; rules updated via suricata-update |
| Wazuh Agent | SIEM agent; requires an existing Wazuh Manager; integrates Suricata and auditd logs |
| UFW IP Blocklist | Daily cron job pulls and applies an IP blocklist via ipsum |
| modulejail | Shrink kernel-module attack surface by writing a modprobe.d blacklist for every kernel module not currently in use |
Tools
vim · nano · ne · micro · tmux · btop · ncdu · nmap · lynis · iftop · iotop · sysstat · mc · speedtest-cli · jq · git · git-lfs · curl · wget · rsync · dnsutils · lsof · unzip · zip · p7zip-full · net-tools · debsums · iputils-ping · needrestart · smartmontools (bare metal install only)
| Requirement | Detail |
|---|---|
| OS | Ubuntu 24.04 LTS or 26.04 LTS Server (minimized or standard) |
| Disk space | 8 GB (minimized install) · 12 GB (standard install) |
| Privileges | Must be run as root (sudo) |
| Network | Active internet connection |
| File | Description |
|---|---|
ubuntu_provision.sh |
Main provisioning script |
ubuntu_provision.conf |
Configuration file (passwords omitted — prompted at runtime) |
ubuntu_backup_config.sh |
Backs up all service configurations to a timestamped tarball |
etc/ |
Service configuration templates with %%PLACEHOLDER%% variables |
All files must sit in the same directory. The script uses its own location as the root for config templates and the backup script.
| Service | Port | Firewall status |
|---|---|---|
| SSH | 22 | allowed |
| Nginx HTTP | 80 | allowed |
| Nginx HTTPS | 443 | allowed |
| MySQL | 3306 | blocked |
| MariaDB | 3306 | blocked |
| PostgreSQL | 5432 | blocked |
| Valkey | 6379 | blocked |
| Mosquitto | 1883 | blocked |
| Postfix | 25 | blocked |
| Monit | 2812 | blocked |
| Grafana | 3000 | blocked |
| Forgejo (standalone) | 3000 | blocked |
| Forgejo (with Grafana) | 3030 | blocked |
| Webmin | 10000 | blocked |
For automated or repeatable deployments, create ubuntu_provision.conf in the same directory as the script. Any variable set in the conf file will skip its interactive prompt. Variables left unset will still be prompted at runtime. Passwords are always prompted interactively and are never read from the conf file.
# ubuntu_provision.conf
# Passwords intentionally omitted - will be prompted
# Set to 'y' to skip confirmation question after configuration section
FORCE_APPLY=n
# Package versions
PG_VERSION=18
PYPY_FALLBACK_VERSION=pypy3.11-v7.3.22
FORGEJO_FALLBACK_VERSION=15.0.2
FORGEJO_FALLBACK_PORT=3030
# INSTALL_DEFAULT sets the default for all INSTALL_ and CONFIGURE_ variables
# not explicitly set. Useful for targeted re-runs, e.g. just updating Postfix:
# INSTALL_DEFAULT=n
# INSTALL_POSTFIX=y
# User
USER_CREATE_SUDO_USER=y
USER_USE_CURRENT_USERNAME=n
USER_SUDO_USER_USERNAME=example
# System
CONFIGURE_LVM=y
LVM_RESIZE_VOLUME=y
LVM_RESIZE_TARGET_GB=all
CONFIGURE_SWAP=y
SWAP_SIZE_GB=2
TUNE_SYSTEM=y
AUTO_UPDATE_DAILY_HOUR=22
AUTO_UPGRADE_DAILY_HOUR=23
DISABLE_TX_OFFLOAD=n
CONFIGURE_APPARMOR=y
APPARMOR_ENABLE=y
APPARMOR_ENFORCE=y
CONFIGURE_MODULEJAIL=y
CONFIGURE_CIS_HARDENING=y
CREATE_HEALTHSCRIPT=y
# Packages
UNINSTALL_PACKAGES=y
UPDATE_PACKAGES=y
INSTALL_PACKAGES=y
# Services. MySQL and MariaDB are mutually exclusive. MySQL has precedence.
INSTALL_UFW=y
INSTALL_SSH=y
INSTALL_FONTS=y
INSTALL_WEASYPRINT=y
INSTALL_IMAGEMAGICK=y
INSTALL_CPYTHON314=y
INSTALL_PYPY311=y
INSTALL_NGINX=y
INSTALL_VALKEY=y
INSTALL_MYSQL=y
INSTALL_MARIADB=n
INSTALL_POSTGRESQL=y
INSTALL_MOSQUITTO=y
INSTALL_POSTFIX=y
INSTALL_MONIT=y
INSTALL_WEBMIN=y
INSTALL_GRAFANA=y
INSTALL_FORGEJO=y
INSTALL_FAIL2BAN=y
INSTALL_AUDITD=y
INSTALL_IPBLOCK=y
INSTALL_SURICATA=y
INSTALL_WAZUH=y
# Fonts tuning
INSTALL_MS_FONTS=y
# MySQL/MariaDB tuning - leave blank to use auto-calculated defaults based on available memory
MYSQL_BUFFER_POOL_MB=
MYSQL_BUFFER_POOL_INSTANCES=
MYSQL_BUFFER_POOL_CHUNK_MB=128
MYSQL_MAX_CONNECTIONS=100
MYSQL_LOG_BUFFER_MB=64
MYSQL_BINLOG_CACHE_MB=16
MYSQL_JOIN_BUFFER_KB=512
MYSQL_SORT_BUFFER_KB=512
MYSQL_READ_BUFFER_KB=128
MYSQL_READ_RND_BUFFER_KB=1024
# Passwords intentionally omitted - will always be prompted:
# MYSQL_PASSWORD=
# PostgreSQL tuning - leave blank to use auto-calculated defaults based on available memory
PG_MAX_CONNECTIONS=100
PG_SHARED_BUFFERS_MB=
PG_WORK_MEM_MB=
PG_EFFECTIVE_CACHE_MB=
PG_MAX_WORKER_PROCESSES=
PG_MAX_PARALLEL_WORKERS=
PG_MAX_PARALLEL_WORKERS_PG=
PG_EFFECTIVE_IO_CONCURRENCY=100
# Password intentionally omitted - will always be prompted:
# PG_PASS=
# Postfix relay-only SMTP
POSTFIX_RELAY_HOST=smtp.example.com
POSTFIX_RELAY_PORT=587
[email protected]
POSTFIX_DOMAIN=example.com
[email protected]
[email protected]
# Password intentionally omitted - will always be prompted:
# POSTFIX_RELAY_PASSWORD=
# Monit
MONIT_USE_POSTFIX=y
MONIT_MAILSERVER_HOST=smtp.example.com
MONIT_MAILSERVER_PORT=587
[email protected]
[email protected]
[email protected]
MONIT_ADMIN_USERNAME=admin
# Passwords intentionally omitted - will always be prompted:
# MONIT_MAILSERVER_PASSWORD=
# MONIT_ADMIN_PASSWORD=
# Grafana
GRAFANA_USE_POSTFIX=y
GRAFANA_SMTP_ENABLED=true
GRAFANA_SMTP_HOST=smtp.example.com
GRAFANA_SMTP_PORT=587
[email protected]
[email protected]
GRAFANA_SMTP_FROM_NAME=Grafana
GRAFANA_SMTP_EHLO_IDENTITY=example.com
GRAFANA_SMTP_STARTTLS_POLICY=MandatoryStartTLS
# Password intentionally omitted - will always be prompted:
# GRAFANA_SMTP_PASSWORD=
# Forgejo
FORGEJO_DOMAIN=example.com
FORGEJO_PORT=3030
FORGEJO_USE_POSTFIX=y
FORGEJO_MAILER_ENABLED=true
FORGEJO_SMTP_ADDR=smtp.example.com
FORGEJO_SMTP_PORT=587
[email protected]
[email protected]
# Password intentionally omitted - will always be prompted:
# FORGEJO_SMTP_PASSWORD=
# Wazuh
WAZUH_MANAGER=wazuh.example.comNote: Keep the conf file out of public repositories — it contains usernames and hostnames. Passwords are always prompted interactively and never stored in the saved conf file. But if you add them manually they will be considered.
Service configuration files live in etc/ mirroring the target filesystem layout:
etc/
mysql/mysql.conf.d/mysqld.cnf
nginx/nginx.conf
nginx/sites-available/default
postgresql/postgresql.conf
postgresql/pg_hba.conf
postfix/main.cf
valkey/valkey.conf
grafana/grafana.ini
forgejo/app.ini
monit/monitrc
monit/conf-available/
monit/conf-available/password-expiry
systemd/system/disable-offload.service
usr/
local/bin/check-password-expiry.sh
Files containing %%PLACEHOLDER%% variables are copied to the target path after all services are installed and substituted with sed. Files without placeholders are copied as-is.
| Placeholder | Value |
|---|---|
%%POSTFIX_RELAY_HOST%% |
SMTP relay hostname or ip |
%%POSTFIX_RELAY_PORT%% |
SMTP relay port, e.g. 587 |
%%POSTFIX_RELAY_USERNAME%% |
Username for SMTP host |
%%POSTFIX_RELAY_PASSWORD%% |
Password for POSTFIX_RELAY_USERNAME at POSTFIX_RELAY_HOST |
%%POSTFIX_DOMAIN%% |
Mail domain (also written to /etc/mailname) |
%%POSTFIX_FROM_ADDRESS%% |
From address |
%%POSTFIX_ROOT_ALIAS%% |
Forward local root mail to this address |
sasl_passwd, sender_canonical_maps, header_check, and generic are written directly by the script using POSTFIX_FROM_ADDRESS — no templates needed for these.
| Placeholder | Value |
|---|---|
%%PRIMARY_INTERFACE%% |
Auto-detected primary network interface |
| Placeholder | Value |
|---|---|
%%PG_MAX_CONNECTIONS%% |
max_connections |
%%PG_SHARED_BUFFERS%% |
shared_buffers (e.g. 256MB) |
%%PG_WORK_MEM%% |
work_mem |
%%PG_EFFECTIVE_CACHE_SIZE%% |
effective_cache_size |
%%PG_MAX_WORKER_PROCESSES%% |
max_worker_processes |
%%PG_MAX_PARALLEL_WORKERS%% |
max_parallel_workers |
%%PG_MAX_PARALLEL_WORKERS_PG%% |
max_parallel_workers_per_gather |
%%PG_EFFECTIVE_IO_CONCURRENCY%% |
effective_io_concurrency |
A pg_hba.conf template is also required at etc/postgresql/pg_hba.conf.
| Placeholder | Value |
|---|---|
%%MONIT_HOST_NAME%% |
Automatically set from hostname -f |
%%MONIT_MAILSERVER_HOST%% |
Mail server hostname (localhost if via Postfix) |
%%MONIT_MAILSERVER_PORT%% |
Mail server port (25 if via Postfix) |
%%MONIT_MAILSERVER_USERNAME%% |
Mail server username (empty if via Postfix) |
%%MONIT_MAILSERVER_PASSWORD%% |
Mail server password (empty if via Postfix) |
%%MONIT_ADMIN_USERNAME%% |
Monit web UI username |
%%MONIT_ADMIN_PASSWORD%% |
Monit web UI password |
%%MONIT_ALERT_SENDER%% |
Alert sender address |
%%MONIT_ALERT_RECIPIENT%% |
Alert recipient address |
monitrc is set to chmod 600 since it contains plaintext credentials.
| Placeholder | Value |
|---|---|
%%GRAFANA_RANDOM_SECRET%% |
Auto-generated random secret key |
%%GRAFANA_SMTP_ENABLED%% |
true or false |
%%GRAFANA_SMTP_HOST%% |
SMTP host (localhost if via Postfix) |
%%GRAFANA_SMTP_PORT%% |
SMTP port (25 if via Postfix) |
%%GRAFANA_SMTP_USER%% |
SMTP username (empty if via Postfix) |
%%GRAFANA_SMTP_PASSWORD%% |
SMTP password (empty if via Postfix) |
%%GRAFANA_SMTP_FROM_ADDRESS%% |
From address |
%%GRAFANA_SMTP_FROM_NAME%% |
From name |
%%GRAFANA_SMTP_EHLO_IDENTITY%% |
EHLO identity (empty if via Postfix) |
%%GRAFANA_SMTP_STARTTLS_POLICY%% |
NoConfig if via Postfix, else MandatoryStartTLS |
| Placeholder | Value |
|---|---|
%%USER_SUDO_USER_USERNAME%% |
Sudo username — substituted at install time |
Called by Monit to alert when the sudo user's password is approaching expiry. Alerts at 30, 20, 10, and 5 days remaining. Exits 0 (OK) if the password is set to never expire.
| Placeholder | Value |
|---|---|
%%FORGEJO_DOMAIN%% |
Domain name or IP (defaults to server's primary IP) |
%%FORGEJO_PORT%% |
HTTP port (3000, or 3030 if Grafana also installed) |
%%FORGEJO_MAILER_ENABLED%% |
true or false |
%%FORGEJO_SMTP_ADDR%% |
SMTP host (empty if via Postfix) |
%%FORGEJO_SMTP_PORT%% |
SMTP port (empty if via Postfix) |
%%FORGEJO_SMTP_FROM%% |
From address |
%%FORGEJO_SMTP_USER%% |
SMTP username (empty if via Postfix) |
%%FORGEJO_SMTP_PASSWORD%% |
SMTP password (empty if via Postfix) |
%%FORGEJO_DB_TYPE%% |
sqlite3/mysql/postgres. Default is sqlite3 |
%%FORGEJO_DB_NAME%% |
Database name (empty for sqlite3) |
%%FORGEJO_DB_USER%% |
Database username (empty for sqlite3) |
%%FORGEJO_DB_PASSWORD%% |
Database password (empty for sqlite3) |
%%FORGEJO_DB_HOST%% |
Database host (empty for sqlite3) |
ubuntu_backup_config.sh captures a full snapshot of service configs, users, SSH keys, cron jobs, systemd overrides, APT sources and GPG keys, Let's Encrypt certificates, and the Grafana and Forgejo databases.
sudo ./ubuntu_backup_config.sh [identifier]If no argument is supplied, the server's IP address is used as the identifier. Output is a timestamped tarball in the current directory:
ubuntu_config_<identifier>_<date>.tar.gz
The provisioning script calls this automatically twice:
- Before prompting — baseline snapshot of the machine as found
- After prompting, before applying — snapshot taken immediately after the operator confirms the printed configuration, before any changes are made
After provisioning, a custom health check script is generated at:
/home/<username>/ubuntu_health_check.sh
Run it any time (as root) to get a colour-coded status report covering:
- SSH configuration
- Swap and disk usage
- Memory usage
- TCP transmit offloading status
- Failed systemd units
- Recent failed SSH logins
- Pending package updates
- Service status for all installed services
- AppArmor profile status per service
- Recent AppArmor denials
- Port usage with UFW allow/block status
- Smoke tests — HTTP connectivity checks for Nginx, Grafana, Forgejo, Monit, and Webmin; database connection tests for MySQL/MariaDB (via a passwordless
healthcheckuser), PostgreSQL, and Valkey; and a Mosquitto pub/sub test
sudo ~/ubuntu_health_check.sh- CIS Level 1 hardening: applies a broad set of CIS benchmark controls in a single optional step (
CONFIGURE_CIS_HARDENING). Controls are applied idempotently — safe to re-run. File integrity monitoring uses AIDE unless Wazuh is installed or selected, in which case Wazuh FIM is used instead (real-time via inotify, feeding into the Wazuh dashboard). A Monit check for sudo user password expiry is added when both Monit and a sudo user are configured — alerts are sent at 30, 20, 10, and 5 days remaining. Postfix is set toinet_interfaces = loopback-onlyregardless of the value inmain.cfto satisfy the CIS "Disable Postfix Network Listening" control. - MySQL / MariaDB health check user: a passwordless
healthcheck@localhostuser with SELECT-only access is created during installation. The health check script uses this user for connection tests — no root credentials are stored anywhere in the health check script. - curl and IPv6: on Ubuntu 26.04, curl prefers IPv6 when connecting to
localhost. The health check smoke tests usecurl -4to force IPv4, ensuring consistent behaviour regardless of howlocalhostresolves on the system. - Idempotent-friendly: the script detects already-installed services and prompts to
reinstallrather than blindly overwriting. On re-runs, services that were skipped are still detected as installed and their configs updated. INSTALL_DEFAULT: set this in the conf file to apply a defaultyornto allINSTALL_andCONFIGURE_variables not explicitly set. Useful for targeted re-runs — e.g.INSTALL_DEFAULT=nwithINSTALL_POSTFIX=yto update only Postfix.- SSH lockout prevention: if you choose to harden SSH, the script requires a valid public key before proceeding — it will abort rather than risk locking you out.
- Postfix: configured as relay-only (
inet_interfaces = 127.0.0.1). Does not accept external mail. Rewrites all outgoing sender addresses toPOSTFIX_FROM_ADDRESSviasender_canonical_maps,header_check, andgeneric. SASL credentials are written tosasl_passwd, hashed withpostmap, and the plaintext file is deleted immediately.mydestinationis set to localhost only — the mail domain must not appear there or Postfix will try to deliver locally instead of relaying. - Monit / Grafana / Forgejo: if Postfix is installed, each service is offered the option to route mail through it (
localhost:25) instead of providing individual SMTP credentials. - AppArmor: enabled and set to enforce mode for profiles that exist on disk (
mysqld,rsyslogd,mosquittoon Ubuntu 26.04). Services without profiles (nginx,postgresql,postfix,grafana,forgejo) are set to complain mode automatically soaa-logprofcan build profiles from real usage. Runsudo aa-logprofafter normal use, thensudo aa-enforceto activate. - Ubuntu Pro: if the server is not attached to Ubuntu Pro, background Pro services (
ubuntu-pro-esm-cache,ubuntu-pro-apt-news) are disabled to prevent AppArmor noise and unnecessary resource use. - TCP TX offloading: disabled via a systemd oneshot service on the primary interface. Resolves edge cases where high dashboard load in Grafana causes CPU spikes from software checksumming.
- smartmontools: only installed on bare metal (
systemd-detect-virtreturnsnone). Skipped silently on virtual machines. - Wazuh: requires an existing Wazuh Manager. The agent is configured to forward Suricata (
eve.json) and auditd logs to the manager automatically. - Forgejo & Grafana: if both are installed, Forgejo is shifted to port 3030 to avoid a conflict. On re-runs, the port is read from
/etc/forgejo/app.iniif not set in the conf file. - PostgreSQL version is pinned to 18 (configurable via
PG_VERSIONin the conf file or at the top of the script). - MySQL and PostgreSQL tuning values are auto-calculated from available RAM and CPU at runtime if left blank in the conf file.
work_memis calculated as 25% of RAM divided bymax_connections, floored at 4MB and capped at 16MB, since it is allocated per sort/hash operation and multiplies under concurrent load. - File limits:
nofileandnprocare set via a drop-in inlimits.d. Note these only apply to PAM-authenticated sessions — systemd services use their ownLimitNOFILEdirectives in their unit files. - Python venvs:
setuptoolsandwheelare not installed at the system level. Install them per-project inside a venv:python3.14 -m venv /path/to/venv && pip install setuptools wheel. - PyPy: installed to
/optand symlinked into/usr/local/bin. The latest PyPy 3.11 release is fetched dynamically at install time, with a hardcoded fallback version if the fetch fails. - Webmin: installed via the official
webmin-setup-repo.shscript, which handles GPG keys and repo configuration automatically.
Enable with CONFIGURE_CIS_HARDENING=y. All controls are applied idempotently — safe to re-run.
| Area | Control |
|---|---|
| Packages | Removes ftp, telnet, tnftp |
| PAM passwords | pam_pwquality: min 14 chars, 3 character classes, max 5 consecutive identical chars |
| PAM history | pam_pwhistory: remembers last 5 passwords |
| PAM lockout | pam_faillock: locks account after 5 failed attempts, unlocks after 15 minutes |
| Password ageing | Max 365 days, min 1 day, warn at 14 days — applied to all non-system accounts |
| Inactivity lock | Account locked 30 days after password expiry |
| Umask | 027 set in login.defs, /etc/profile, /etc/bash.bashrc |
| su restriction | pam_wheel restricts su to members of the wheel group |
| System accounts | All system accounts (UID < 1000) set to /usr/sbin/nologin, except root and service accounts that legitimately need a shell (e.g. forgejo) |
| Cron/at | Directories set to 700, crontab to 600. /etc/cron.allow and /etc/at.allow restrict scheduling to root and the sudo user |
| SSH | LogLevel INFO, warning banner, no host-based auth, no user environment, ClientAliveInterval 300. CIS cipher suite, MACs, and key exchange algorithms enforced |
| Sudo logging | Defaults logfile="/var/log/sudo.log" (skipped on Ubuntu 26.04 which uses sudo-rs and logs to journald) |
| Core dumps | Disabled via PAM limits, sysctl (fs.suid_dumpable=0), and systemd coredump config |
| sysctl | ASLR, TCP SYN cookies, martian packet logging, ICMP redirect hardening, IPv6 RA disabled |
| Kernel modules | cramfs, freevxfs, hfs, hfsplus, jffs2, udf, usb-storage blacklisted |
| /dev/shm | Mounted with nodev,nosuid,noexec |
| Journald | Forwarded to rsyslog |
| NTP | Configured via systemd-timesyncd using Ubuntu pool servers |
| Session timeout | TMOUT=900 (15 minutes) set readonly in /etc/profile.d/cis-timeout.conf |
| File integrity | AIDE installed and initialised unless Wazuh is present (Wazuh FIM used instead) |
| Postfix | inet_interfaces = loopback-only if Postfix is installed |
| Password expiry alert | Monit check alerts at 30, 20, 10, and 5 days remaining (requires Monit and a sudo user) |
| Apport | Disabled and masked |
Two intentional deviations from strict CIS Level 1:
- Password complexity: CIS requires all 4 character classes (
dcredit,ucredit,ocredit,lcredit = -1). This script usesminclass = 3instead, in line with NIST SP 800-63B guidance that length matters more than forced complexity. The 14-character minimum is preserved. maxrepeat: set to5rather than the CIS default of3, to accommodate passphrase-style passwords with separator characters (e.g.correct-horse-battery-staple) which would otherwise be rejected due to repeated hyphens.
If PAM is misconfigured and authentication breaks, the fastest recovery is:
# From a root console (cloud provider web console / IPMI)
pam-auth-update --force
faillock --user YOUR_USERNAME --reset
passwd YOUR_USERNAMESee howto/howto-pam-recovery.md for full recovery procedures including GRUB single-user mode and chroot from a rescue image.
Lynis is installed from the CISOfy repository to ensure the current release is used. The Ubuntu universe package lags significantly behind.
# Full system audit
sudo lynis audit system
# Show the hardening index score
sudo lynis audit system | grep "Hardening index"
# Audit only a specific category
sudo lynis audit system --tests-category authentication
sudo lynis audit system --tests-category networking
sudo lynis audit system --tests-category storage| File | Contents |
|---|---|
/var/log/lynis.log |
Full detailed audit log |
/var/log/lynis-report.dat |
Machine-readable report (key=value format) |
Lynis produces Suggestions and Warnings:
- Warnings (marked
[WARNING]) are higher priority — address these first. - Suggestions (marked
[suggestion]) are improvements but not necessarily failures. - The Hardening Index (0–100) gives an overall score. A freshly CIS-hardened server typically scores 75–85. Getting above 90 requires additional manual controls beyond what this script applies.
Some Lynis findings are intentional and can be acknowledged:
| Finding | Reason acceptable |
|---|---|
AUTH-9282 — password complexity |
Intentional exception — minclass=3 chosen over strict 4-class requirement |
KRNL-5820 — kernel update available |
Ubuntu kernel updates are handled by unattended-upgrades |
FIRE-4513 — no outbound firewall rules |
UFW outbound is open by design; restrict further if your threat model requires it |
MAIL-8818 — Postfix listening |
Only on loopback (inet_interfaces = loopback-only) — this is correct |
Lynis is installed via direct .deb download rather than an apt repo. Use the upgrade script:
sudo ./utils/upgrade_lynis.sh
lynis show versionThe following are installed from apt repositories and update automatically with sudo apt-get update && sudo apt-get upgrade (or via unattended-upgrades if enabled):
| Package | Repository |
|---|---|
| Ubuntu system packages (nginx, postfix, mysql, mariadb, postgresql, valkey, suricata, fail2ban, auditd, monit, etc.) | Ubuntu / Ubuntu PPA |
| PostgreSQL | apt.postgresql.org |
| Grafana | apt.grafana.com |
| Webmin | Webmin apt repo (configured by webmin-setup-repo.sh) |
| Python 3.14 | deadsnakes PPA (ppa:deadsnakes/ppa) |
The following require manual upgrade steps. Check for new releases and follow the instructions below.
The Wazuh Agent version must never exceed the manager version, or the agent may fail to connect or drop events silently.
Wazuh Agent is installed from the Wazuh apt repo but is pinned with apt-mark hold to prevent it auto-upgrading.
Always upgrade the Wazuh Manager first, then upgrade agents to match.
There is no way to query the manager version from the agent host — check it on the manager server before running the upgrade:
# On the manager server
/var/ossec/bin/wazuh-control info | grep VERSIONThen run the upgrade script on each agent:
# Prompts for the target version
sudo ./utils/upgrade_wazuh_agent.sh
# Or pass the version directly
sudo ./utils/upgrade_wazuh_agent.sh 4.14.7The script unholds the package, installs the specified version, re-pins with apt-mark hold, restarts the service, and verifies the result.
Lynis is installed by downloading the .deb directly from CISOfy rather than adding their apt repo. The CISOfy repo requires a GPG key that can fail to download or validate, which would cause every subsequent apt-get update call in the script — including those triggered internally by Grafana, PostgreSQL, and other package installs — to fail with a signature error and abort the run. Downloading the .deb directly avoids registering anything in sources.list.d and keeps Lynis completely isolated from the rest of the apt stack. Any leftover CISOfy repo files from earlier runs are removed at the start of section 8 before apt-get update runs.
# Upgrade to latest
sudo ./utils/upgrade_lynis.sh
# Or pin to a specific version
sudo ./utils/upgrade_lynis.sh 3.1.1The script fetches the latest version from the CISOfy GitHub API, compares it to the installed version, and skips if already current. If the CISOfy download fails, it falls back to apt-get install lynis from Ubuntu universe and tells you whether the universe version matches the target or is older.
Forgejo is downloaded directly from Codeberg. The script fetches the latest release at install time and falls back to FORGEJO_FALLBACK_VERSION if the API is unreachable.
Use the upgrade script in utils/:
# Upgrade to latest
sudo ./utils/upgrade_forgejo.sh
# Or pin to a specific version
sudo ./utils/upgrade_forgejo.sh 16.0.4The script fetches the latest release from the Codeberg API, backs up the database (sqlite3, MySQL, or PostgreSQL — detected automatically from /etc/forgejo/app.ini), stops the service, replaces the binary, and restarts. Backups are written to /var/backups/forgejo/.
PyPy is downloaded from downloads.python.org/pypy and installed to /opt/. The symlinks /usr/local/bin/pypy3, pypy3.11, and pypy point to the active version. Old versions in /opt/ are left in place and must be removed manually.
# Upgrade to latest
sudo ./utils/upgrade_pypy.sh
# Or pin to a specific version
sudo ./utils/upgrade_pypy.sh pypy3.11-v7.3.23Modulejail is a one-shot script that blacklists unused kernel modules by writing /etc/modprobe.d/modulejail-blacklist.conf. It does not run as a service. Re-run it after adding new services to pick up any newly loaded modules. A reboot is required for blacklist changes to take effect.
# Re-run at latest version
sudo ./utils/upgrade_modulejail.sh
# Or use a specific version
sudo ./utils/upgrade_modulejail.sh 1.6.1The script preserves the whitelist at /etc/modulejail/whitelist.conf and backs up the existing blacklist before regenerating it.
- Does not configure network interfaces or disk partitions beyond LVM resizing and swap creation.
- Does not back up or restore MySQL/PostgreSQL databases — only service configuration files.
- Requires internet access at runtime to download packages and (for Forgejo and PyPy) fetch the latest release version.
/etc/security/limits.d/changes only apply to PAM-authenticated sessions. Services managed by systemd use their ownLimitNOFILEdirectives in their unit files or overrides.- All blocked services are only accessible via localhost or through Nginx as a reverse proxy (not configured).
MIT — use freely, modify as needed.