Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/shellcheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: ShellCheck

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Syntax check
run: for f in ./*.sh; do bash -n "$f"; done
- name: ShellCheck (preinstalled on GitHub runners)
run: shellcheck --severity=style ./*.sh
- name: Dry run
run: LOG_FILE=/tmp/provision.log ./provision.sh --dry-run
60 changes: 45 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,57 @@
# Linux Server Provisioning

Bash automation scripts to provision and monitor an Ubuntu web server on AWS EC2.
Bash scripts that turn a fresh Ubuntu/Debian server (e.g. an AWS EC2 instance) into an nginx web server behind a UFW firewall, plus a health-check script with monitoring-friendly exit codes.

## Scripts

### provision.sh
Automates full server setup:
- Updates system packages
- Installs and configures nginx
- Sets up UFW firewall (allows SSH and HTTP)
- Deploys a custom homepage
### `provision.sh`

### healthcheck.sh
Monitors server health:
- Disk usage
- Memory usage
- Nginx service status
| Step | What it does |
|---|---|
| Preflight | Requires root (except `--dry-run`), checks the OS is Ubuntu/Debian, validates `SSH_SOURCE_CIDR` |
| Packages | `apt-get update/upgrade`, installs `nginx`, `ufw`, `curl` only if missing |
| Firewall | Default deny incoming. Allows SSH **before** enabling UFW, so the current session is not locked out. SSH can be restricted to one CIDR. HTTP 80 open |
| Homepage | Writes `/var/www/html/index.html` atomically; skipped if already up to date |
| nginx | Validates config with `nginx -t`, enables and reloads the service |
| Verify | Checks that `http://127.0.0.1/` responds |

Built for safe repeated runs: `set -Eeuo pipefail`, an error trap that logs the failing line and command, timestamped logs to `/var/log/provision.log`, and idempotent steps.

### `healthcheck.sh`

Checks root disk usage, memory (based on `MemAvailable`), systemd services and an HTTP endpoint. Thresholds are configurable. Exit codes: `0` OK, `1` WARNING, `2` CRITICAL, so it can run from cron or a monitoring agent.

## Usage

```bash
chmod +x provision.sh
./provision.sh
chmod +x provision.sh healthcheck.sh

sudo ./provision.sh --dry-run # show what would happen
sudo SSH_SOURCE_CIDR=203.0.113.4/32 ./provision.sh # SSH only from your IP
./healthcheck.sh
DISK_WARN=70 SERVICES="nginx ssh" ./healthcheck.sh
```

| Variable | Default | Script |
|---|---|---|
| `SSH_SOURCE_CIDR` | empty = SSH from anywhere (logs a warning) | provision |
| `LOG_FILE` | `/var/log/provision.log` | provision |
| `DISK_WARN` / `DISK_CRIT` | 80 / 90 (%) | healthcheck |
| `MEM_WARN` / `MEM_CRIT` | 85 / 95 (%) | healthcheck |
| `SERVICES` | `nginx` | healthcheck |
| `HTTP_URL` | `http://127.0.0.1/` (empty = skip) | healthcheck |

## Security notes

- Restrict SSH with `SSH_SOURCE_CIDR`, or better, use AWS Systems Manager Session Manager and close port 22 completely (see my [aws-terraform-infrastructure](https://github.com/MultiCloudEng/aws-terraform-infrastructure) project).
- Only HTTP (80) is opened. For production, add TLS (e.g. certbot) and allow 443.

## What was tested

- The original version was run on an Ubuntu EC2 instance.
- The current version was tested on Ubuntu 24.04 (not EC2): `--dry-run` as a non-root user, rejection of an invalid CIDR, an unknown argument and a non-root real run, and `healthcheck.sh` including forced WARNING/CRITICAL thresholds. ShellCheck reports no findings.
- A full real provisioning run of the current version (package install, UFW, systemd) has not been repeated yet. Run it on a fresh EC2 instance before relying on it.

## Skills
Linux administration, bash scripting, nginx, UFW firewall, AWS EC2, automation.

Linux administration, Bash (strict mode, traps, idempotency), nginx, UFW, systemd, monitoring basics.
101 changes: 89 additions & 12 deletions healthcheck.sh
Original file line number Diff line number Diff line change
@@ -1,17 +1,94 @@
#!/bin/bash
#!/usr/bin/env bash
# Server health check: disk, memory, services and HTTP.
# Exit codes (usable from cron or a monitoring agent):
# 0 = OK, 1 = WARNING, 2 = CRITICAL
#
# Usage:
# ./healthcheck.sh
# DISK_WARN=70 SERVICES="nginx ssh" ./healthcheck.sh
#
# Environment variables (defaults in brackets):
# DISK_WARN [80] / DISK_CRIT [90] root filesystem usage in %
# MEM_WARN [85] / MEM_CRIT [95] memory usage in % (based on MemAvailable)
# SERVICES [nginx] systemd services that must be active
# HTTP_URL [http://127.0.0.1/] URL that must return HTTP 2xx/3xx (empty = skip)

echo "=== Server Health Check ==="
echo "Date: $(date)"
set -Euo pipefail

echo ""
echo "--- Disk Usage ---"
df -h /
DISK_WARN="${DISK_WARN:-80}"
DISK_CRIT="${DISK_CRIT:-90}"
MEM_WARN="${MEM_WARN:-85}"
MEM_CRIT="${MEM_CRIT:-95}"
SERVICES="${SERVICES:-nginx}"
HTTP_URL="${HTTP_URL-http://127.0.0.1/}"

echo ""
echo "--- Memory Usage ---"
free -h
status=0
report() {
# $1 = level (OK/WARN/CRIT/UNKNOWN), rest = message
local level="$1"; shift
printf '%-8s %s\n' "[$level]" "$*"
case "$level" in
WARN|UNKNOWN) (( status < 1 )) && status=1 ;;
CRIT) status=2 ;;
esac
}

echo ""
echo "--- Nginx Status ---"
systemctl is-active nginx
check_threshold() {
# $1 = name, $2 = value, $3 = warn, $4 = crit
if (( $2 >= $4 )); then report CRIT "$1 at $2% (critical >= $4%)"
elif (( $2 >= $3 )); then report WARN "$1 at $2% (warning >= $3%)"
else report OK "$1 at $2%"
fi
}

check_disk() {
local used
used="$(df -P / | awk 'NR==2 {gsub("%","",$5); print $5}')"
[[ "$used" =~ ^[0-9]+$ ]] || { report UNKNOWN "could not read disk usage"; return; }
check_threshold "Disk /" "$used" "$DISK_WARN" "$DISK_CRIT"
}

check_memory() {
local total avail used
total="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)"
avail="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
[[ -n "$total" && -n "$avail" && "$total" -gt 0 ]] || { report UNKNOWN "could not read memory usage"; return; }
used=$(( (total - avail) * 100 / total ))
check_threshold "Memory" "$used" "$MEM_WARN" "$MEM_CRIT"
}

check_services() {
local svc state
if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then
report UNKNOWN "systemd not available; cannot check services ($SERVICES)"
return
fi
for svc in $SERVICES; do
state="$(systemctl is-active "$svc" 2>/dev/null || true)"
if [[ "$state" == "active" ]]; then report OK "Service $svc is active"
else report CRIT "Service $svc is ${state:-unknown}"
fi
done
}

check_http() {
[[ -z "$HTTP_URL" ]] && return
local code
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HTTP_URL" || true)"
if [[ "$code" =~ ^[23][0-9][0-9]$ ]]; then report OK "HTTP $HTTP_URL returned $code"
else report CRIT "HTTP $HTTP_URL returned ${code:-no response}"
fi
}

echo "=== Server health check: $(hostname) $(date '+%Y-%m-%d %H:%M:%S') ==="
check_disk
check_memory
check_services
check_http
case "$status" in
0) overall=OK ;;
1) overall=WARNING ;;
*) overall=CRITICAL ;;
esac
echo "=== Overall: $overall ==="
exit "$status"
167 changes: 156 additions & 11 deletions provision.sh
Original file line number Diff line number Diff line change
@@ -1,17 +1,162 @@
#!/bin/bash
echo "Starting server provisioning..."
sudo apt update
sudo apt install nginx -y
#!/usr/bin/env bash
# Provision an Ubuntu/Debian web server: system updates, nginx, UFW firewall,
# custom homepage. Safe to run more than once (idempotent).
#
# Usage:
# sudo ./provision.sh # provision
# sudo ./provision.sh --dry-run # print what would be done, change nothing
# sudo SSH_SOURCE_CIDR=203.0.113.4/32 ./provision.sh # allow SSH only from one IP
#
# Environment variables:
# SSH_SOURCE_CIDR CIDR allowed to reach SSH (port 22). Default: anywhere (with a warning).
# LOG_FILE Log file path. Default: /var/log/provision.log

set -Eeuo pipefail

echo "Configuring firewall..."
sudo ufw allow 22
sudo ufw allow 80
sudo ufw --force enable
LOG_FILE="${LOG_FILE:-/var/log/provision.log}"
SSH_SOURCE_CIDR="${SSH_SOURCE_CIDR:-}"
WEB_ROOT="/var/www/html"
HOMEPAGE="${WEB_ROOT}/index.html"
DRY_RUN=false

log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2" | tee -a "$LOG_FILE" >&2; }
info() { log INFO "$*"; }
warn() { log WARN "$*"; }
die() { log ERROR "$*"; exit 1; }

echo "Creating custom homepage..."
echo "<h1>Provisioned by Asadbek's script</h1>" | sudo tee /var/www/html/index.nginx-debian.html
# Print the failing command and line number instead of exiting silently.
trap 'die "Command failed (exit $?) at line ${LINENO}: ${BASH_COMMAND}"' ERR

# Run a command, or only print it in dry-run mode.
run() {
if "$DRY_RUN"; then
info "[dry-run] $*"
else
info "+ $*"
"$@"
fi
}

echo "Provisioning complete!"
usage() {
sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'
}

parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
-h|--help) usage; exit 0 ;;
*) usage; die "Unknown argument: $1" ;;
esac
shift
done
}

preflight() {
if "$DRY_RUN"; then
# Dry run must work without root, so log to a temp file if needed.
if ! touch "$LOG_FILE" 2>/dev/null; then LOG_FILE="$(mktemp -t provision.XXXX.log)"; fi
else
[[ $EUID -eq 0 ]] || { echo "Run as root: sudo $0" >&2; exit 1; }
touch "$LOG_FILE"
chmod 640 "$LOG_FILE"
fi

[[ -r /etc/os-release ]] || die "Cannot detect the OS (/etc/os-release missing)."
# shellcheck disable=SC1091
. /etc/os-release
case "${ID:-}" in
ubuntu|debian) info "Detected ${PRETTY_NAME:-$ID}" ;;
*) die "Unsupported OS '${ID:-unknown}'. This script supports Ubuntu and Debian." ;;
esac

if [[ -n "$SSH_SOURCE_CIDR" ]] && ! [[ "$SSH_SOURCE_CIDR" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; then
die "SSH_SOURCE_CIDR '$SSH_SOURCE_CIDR' is not a valid IPv4 CIDR (e.g. 203.0.113.4/32)."
fi
}

install_packages() {
export DEBIAN_FRONTEND=noninteractive
run apt-get update -y
run apt-get upgrade -y
local pkg missing=()
for pkg in nginx ufw curl; do
if dpkg -s "$pkg" >/dev/null 2>&1; then
info "$pkg already installed"
else
missing+=("$pkg")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
run apt-get install -y --no-install-recommends "${missing[@]}"
fi
}

configure_firewall() {
run ufw default deny incoming
run ufw default allow outgoing

# Allow SSH BEFORE enabling the firewall, otherwise the current session is locked out.
if [[ -n "$SSH_SOURCE_CIDR" ]]; then
run ufw allow from "$SSH_SOURCE_CIDR" to any port 22 proto tcp comment 'SSH (restricted)'
else
warn "SSH_SOURCE_CIDR not set: SSH (22) is allowed from anywhere. Restrict it in production."
run ufw allow 22/tcp comment 'SSH'
fi
run ufw allow 80/tcp comment 'HTTP'
run ufw --force enable
}

deploy_homepage() {
local content
content="<!doctype html>
<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Provisioned</title></head>
<body><h1>Provisioned by Asadbek's script</h1><p>Host: $(hostname)</p></body></html>"

if [[ -f "$HOMEPAGE" ]] && [[ "$(cat "$HOMEPAGE")" == "$content" ]]; then
info "Homepage already up to date"
return
fi
if "$DRY_RUN"; then
info "[dry-run] write $HOMEPAGE"
else
install -d -m 755 "$WEB_ROOT"
printf '%s\n' "$content" > "${HOMEPAGE}.tmp"
chmod 644 "${HOMEPAGE}.tmp"
mv "${HOMEPAGE}.tmp" "$HOMEPAGE" # atomic replace
info "Homepage written to $HOMEPAGE"
fi
}

start_nginx() {
run nginx -t # validate config before (re)starting
run systemctl enable --now nginx
run systemctl reload nginx
}

verify() {
"$DRY_RUN" && { info "[dry-run] skip verification"; return; }
local _
for _ in 1 2 3 4 5; do
if curl -fsS --max-time 3 http://127.0.0.1/ >/dev/null; then
info "nginx responds on http://127.0.0.1/"
return
fi
sleep 1
done
die "nginx is not responding on port 80"
}

main() {
parse_args "$@"
preflight
info "Starting provisioning (dry-run=$DRY_RUN, log=$LOG_FILE)"
install_packages
configure_firewall
deploy_homepage
start_nginx
verify
info "Provisioning complete"
}

main "$@"
Loading