How the toolkit fits together — for contributors and operators extending or adapting it.
linux-security is organized around four concerns:
- Configuration — two files (
config.envandconfig.web.env) hold all environment-specific values - Profiles — a profile file selects which scripts run for a given server type
- Hardening — sequential scripts apply controls to a live server, split between core (any server) and web (Apache/PHP/MySQL addendum)
- Verification — the audit script checks that controls are active after the fact
These are intentionally decoupled. Config is separate from scripts. Audit is read-only and separate from hardening. The entire stack can be previewed with --dry-run before anything touches a server.
linux-security/
├── config.env # Core configuration (fill in before running)
├── config.web.env # Web-layer configuration (web-server profile only)
├── bootstrap.sh # Orchestrator: reads profile, sources config, runs scripts
│
├── profiles/
│ ├── baseline.conf # Core-only script list (any Ubuntu/Debian server)
│ └── web-server.conf # Core + web script list (Apache/PHP/MySQL servers)
│
├── scripts/
│ ├── core/
│ │ ├── hardening/ # Core hardening scripts, run in sequence
│ │ │ ├── 01-immediate-hardening.sh
│ │ │ ├── 02-setup-admin-user.sh
│ │ │ ├── 03-monthly-updates-setup.sh
│ │ │ ├── 04-rkhunter-setup.sh
│ │ │ ├── 05-auditd-setup.sh
│ │ │ ├── 06-fail2ban-recidive.sh
│ │ │ ├── 07-aide-setup.sh
│ │ │ ├── 08-disk-alert-setup.sh
│ │ │ └── ssh-key-rotate.sh
│ │ └── audit/ # Standalone read-only checkers (core)
│ │ ├── verify.sh
│ │ ├── firewall-check.sh
│ │ ├── ssh-audit.sh
│ │ ├── ports-check.sh
│ │ └── ...
│ │
│ ├── web/
│ │ ├── hardening/ # Web-layer hardening scripts
│ │ │ ├── 01-apache-hardening.sh
│ │ │ ├── 02-log-monitoring-setup.sh
│ │ │ ├── 03-cert-monitor-setup.sh
│ │ │ ├── 04-clamav-setup.sh
│ │ │ ├── 05-modsecurity-setup.sh
│ │ │ ├── 06-vhost-hardener.sh
│ │ │ ├── 07-apache-tls-hardening.sh
│ │ │ ├── 08-apache-dos-mitigation.sh
│ │ │ ├── 09-php-hardening.sh
│ │ │ ├── 10-mysql-hardening.sh
│ │ │ └── rollback.sh
│ │ └── audit/ # Standalone read-only checkers (web)
│ │ ├── headers-check.sh
│ │ ├── vhost-linter.sh
│ │ ├── web-root-perms.sh
│ │ └── ...
│ │
│ └── audit/
│ └── audit.sh # Profile-aware baseline checker (dispatches to core + web checkers)
│
├── lib/ # Shared shell libraries (output helpers, etc.)
├── docs/ # Operator and contributor documentation
│ ├── architecture.md # This file
│ ├── customization.md # config.env and config.web.env variable reference
│ ├── security/README.md # Security baseline and policy
│ ├── TEMPLATE.md # Audit report template
│ └── VPS_HARDENING_GUIDE.html # Standalone offline reference
│
├── config/ # Config snippets and templates (planned)
├── logs/ # Per-run bootstrap logs (gitignored)
└── private/ # Git submodule — server-specific data (not public)
Profiles live in profiles/ and are plain text files listing one script path per line. bootstrap.sh reads the selected profile and runs each script in order.
| Profile | File | What it runs |
|---|---|---|
baseline |
profiles/baseline.conf |
scripts/core/hardening/ scripts only — no Apache dependency |
web-server |
profiles/web-server.conf |
Core scripts interleaved with scripts/web/hardening/ scripts |
web-server is the default when --profile is omitted, preserving back-compatibility with earlier versions.
The profile selection also controls which config file is loaded: bootstrap.sh sources config.web.env only for the web-server profile. It also controls which audit checks run: audit.sh skips web-layer checks when --profile baseline is passed.
Every script and bootstrap.sh uses the same discovery chain at startup. The first match wins:
1. $CONFIG_FILE environment variable (explicit override)
2. <script-dir>/../../../config.env (repo root when running from scripts/core/hardening/)
3. <script-dir>/../../config.env (repo root when running from scripts/audit/)
4. /etc/linux-security/config.env (system-wide install)
5. (none found) — use per-variable defaults, print a warning
Web scripts follow the same pattern for config.web.env, using $WEB_CONFIG_FILE as the override variable.
This means scripts work correctly whether run via bootstrap.sh (which exports CONFIG_FILE), run directly from the repo, or installed system-wide. No path is hardcoded.
To point scripts at a config file in your private submodule:
export CONFIG_FILE=/path/to/private/config.env.local
bash bootstrap.shEach variable has a safe default or aborts with a clear error if required and unset:
| Variable | Config file | Default | Behavior if unset |
|---|---|---|---|
SSH_PORT |
config.env |
22 |
Uses default |
ADMIN_USER |
config.env |
(none) | Script 02 aborts |
ADMIN_EMAIL |
config.env |
(none) | Scripts abort |
MAIL_FROM |
config.env |
server@<hostname> |
Uses default |
SMTP_HOST |
config.env |
smtp.gmail.com |
Uses default |
SMTP_PORT |
config.env |
587 |
Uses default |
SMTP_USER |
config.env |
(none) | Skips auth block in msmtp config |
CSP_FRAME_ANCESTORS |
config.web.env |
'self' |
Uses default |
CERT_WARN_DAYS |
config.web.env |
30 |
Uses default |
WEB_ROOTS_DIR |
config.web.env |
/var/www |
Uses default |
Scripts within each layer are numbered because order matters. The profile file determines the interleaving between core and web scripts.
Baseline profile (core only):
core/01 → core/02 → core/03 → core/04 → core/05 → core/06 → core/07 → core/08
Web-server profile (core + web interleaved):
core/01 → core/02 → core/03 → web/01 → web/02 → web/03 → core/04 → ... → web/09 → web/10 → core/08
| Script | Layer | Installs / Configures | Dependency |
|---|---|---|---|
core/01 |
core | fail2ban, UFW, SSH config, sysctl | Requires SSH key in /root/.ssh/authorized_keys |
core/02 |
core | Admin user sudo + SSH keys, removes cloud-init sudoers | User must exist; 01 must have run |
core/03 |
core | msmtp, monthly upgrade cron | fail2ban must be installed (01) for reports to query it |
web/01 |
web | Apache security headers, mod_headers, security.conf | Requires Apache running |
web/02 |
web | Logwatch, GoAccess, reports .htaccess | Apache running (web/01); msmtp installed (core/03) |
web/03 |
web | Cert expiry monitor cron + email alert | msmtp installed (core/03); certbot present |
core/04 |
core | rkhunter rootkit scanner | (none) |
core/05 |
core | auditd syscall auditing | (none) |
web/04–10 |
web | ClamAV, ModSecurity, vhost hardening, TLS, DoS mitigation, PHP, MySQL | Apache running; relevant software installed |
core/06 |
core | fail2ban recidive jail | fail2ban installed (01) |
core/07 |
core | AIDE filesystem integrity | (none) |
core/08 |
core | Disk usage alert cron | msmtp installed (core/03) |
Running scripts out of order won't break the server, but report output will be incomplete if dependencies haven't run yet.
bootstrap.sh is a thin orchestrator, not a monolith. It:
- Validates the
--profileargument and reads the corresponding profile file - Requires
config.envto be found (aborts if not — unlike standalone scripts which warn and continue) - Exports
CONFIG_FILEso child scripts skip their own discovery - For the
web-serverprofile, also locates and exportsWEB_CONFIG_FILE - Runs each script listed in the profile with
bash, capturing stdout/stderr to both console and a per-script log file - Stops the chain on the first non-zero exit
- Prints a summary of pass/fail counts and the log directory location
Log files are written to logs/ at the repo root:
logs/
└── 20260308-153042/
├── bootstrap.log (full combined output)
├── 20260308-153042-01-immediate-hardening.log
├── 20260308-153042-web-01-apache-hardening.log
└── ...
The logs/ directory is gitignored. It is appropriate to copy logs to the private submodule after a run for an audit trail.
--dry-run is passed through to every child script. The entire stack can be previewed without a single change to the server.
audit.sh is strictly read-only. It is profile-aware: core checks always run; web checks (headers-check.sh, vhost-linter.sh, certificate checks) are skipped when --profile baseline is passed.
It uses only inspection commands (ufw status, sshd -T, fail2ban-client status, curl -sI, apt list, certbot certificates) and makes no changes.
Check categories and what they verify:
| Category | Profile | Checks |
|---|---|---|
| Firewall | core | UFW active; SSH, HTTP, HTTPS ports open; no unexpected rules |
| SSH | core | PasswordAuthentication no; PermitRootLogin restricted; X11Forwarding no |
| fail2ban | core | Service running; sshd jail active; recidive jail active |
| Updates | core | Pending package count; unattended-upgrades active |
| Apache | web | Service running; ServerTokens Prod; HSTS, X-Content-Type-Options, Referrer-Policy, CSP headers; mod_status disabled |
| fail2ban (Apache jails) | web | apache-auth, apache-badbots, apache-noscript jails active |
| Certificates | web | Cert expiry via certbot certificates; flags anything expiring within CERT_WARN_DAYS |
Exit codes:
| Code | Meaning |
|---|---|
0 |
All checks passed (no FAILs; WARNs are allowed) |
1 |
One or more FAILs |
Output modes:
- Default: color-coded PASS / WARN / FAIL lines with summary counts
--json: machine-readable JSON with host, timestamp, summary, and per-check results — suitable for storing in the private submodule as a timestamped snapshot--report/--report html: formatted audit report in Markdown or HTML
The public repo contains only generic, reusable content: scripts with no server-specific values, documentation, templates. Nothing that identifies a real server.
The private/ directory is a separate private git repository added as a submodule. It holds everything server-specific:
private/
├── servers/
│ └── <hostname>/
│ ├── AUDIT_REPORT.md # Findings with real IPs, ports, hostnames
│ └── notes.md # Operational notes
├── inventory/ # Hardware/VM inventory, IPs, provider accounts
└── network/ # Topology, firewall rule rationale, port maps
Cloning the public repo does not pull the submodule. Only users with access to the private repo can initialize it:
git submodule update --init --recursiveThis is the intended model for adopters: fork the public repo, create your own private companion repo, wire it up as a submodule at private/. Your server-specific data stays private; you still benefit from upstream improvements to the scripts.
Every hardening script implements --dry-run using the same pattern:
DRYRUN=false
for arg in "$@"; do [[ "$arg" == "--dry-run" ]] && DRYRUN=true; done
cmd() {
if $DRYRUN; then echo " [dry-run] $*"; return 0; fi
"$@"
}Executable commands (package installs, systemctl, ufw, sed) go through cmd().
File writes (heredocs) use explicit if ! $DRYRUN; then ... else echo "[dry-run] Would write <path>"; fi blocks to keep the heredoc readable while still printing meaningful output in dry-run mode.
Scripts that require a value from config (like ADMIN_USER) still validate and abort in dry-run mode — you want to catch missing config before a live run, not during one.
The repo is structured for growth. Planned directories and their intended purpose:
| Path | Purpose |
|---|---|
scripts/core/audit/ |
Additional read-only checkers (open ports, SUID baseline, services diff, SSH config, firewall rules) — each a standalone script, runnable individually or composed |
scripts/web/audit/ |
Web-layer checkers (header validation, vhost linting, web root permissions) |
config/ |
Reusable config snippets: example jail.local, sysctl.conf, Apache vhost templates with security headers pre-applied |
When adding a new hardening script:
- Decide the layer:
scripts/core/hardening/for server-agnostic controls,scripts/web/hardening/for anything with an Apache/web dependency - Follow the numbered convention if it belongs in the main sequence, or add it unnumbered if it is optional/standalone
- Source
config.env(andconfig.web.envif needed) using the standard discovery block (copy from any existing script) - Implement
--dry-runusing thecmd()pattern above - Add a pre-flight check for any precondition that could leave the server in a bad state
- Back up any file before overwriting it
- Add the script path to the relevant profile file(s), then update
README.md,CHANGELOG.md, and close the relevant issue