Last active
January 17, 2025 20:55
-
-
Save rameerez/238927b78f9108a71a77aed34208de11 to your computer and use it in GitHub Desktop.
Set up a Ubuntu server to deploy Kamal 2.x Docker containers to, hardened security and production ready
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# Production Docker Host Hardening Script v2 | |
# For Ubuntu Server 24.04 LTS (Noble) | |
# Suitable for both Kamal deployment and builder hosts | |
set -euo pipefail | |
IFS=$'\n\t' | |
# --- Constants --- | |
REQUIRED_OS="Ubuntu" | |
REQUIRED_VERSION="24.04" | |
MIN_RAM_MB=1024 | |
MIN_DISK_GB=20 | |
# --- Aesthetics --- | |
GREEN='\033[0;32m' | |
YELLOW='\033[1;33m' | |
RED='\033[0;31m' | |
ALIEN='\xF0\x9F\x91\xBD' | |
NC='\033[0m' | |
# --- Functions --- | |
print_message() { | |
local color=$1 | |
local message=$2 | |
echo -e "${color}${ALIEN} ${message}${NC}" | |
} | |
print_error() { | |
print_message "${RED}" "ERROR: $1" | |
} | |
print_warning() { | |
print_message "${YELLOW}" "WARNING: $1" | |
} | |
print_success() { | |
print_message "${GREEN}" "SUCCESS: $1" | |
} | |
check_root() { | |
if [[ $EUID -ne 0 ]]; then | |
print_error "This script must be run as root" | |
exit 1 | |
fi | |
} | |
check_os() { | |
if ! command -v lsb_release >/dev/null 2>&1; then | |
print_error "lsb_release command not found. Is this Ubuntu?" | |
exit 1 | |
fi | |
local os_name=$(lsb_release -is) | |
local os_version=$(lsb_release -rs) | |
if [[ "$os_name" != "$REQUIRED_OS" ]]; then | |
print_error "This script requires $REQUIRED_OS (found $os_name)" | |
exit 1 | |
fi | |
if [[ "$os_version" != "$REQUIRED_VERSION" ]]; then | |
print_error "This script requires Ubuntu $REQUIRED_VERSION (found $os_version)" | |
exit 1 | |
fi | |
} | |
check_resources() { | |
local total_ram_mb=$(free -m | awk '/^Mem:/{print $2}') | |
local total_disk_gb=$(df -BG / | awk 'NR==2 {print $4}' | sed 's/G//') | |
if (( total_ram_mb < MIN_RAM_MB )); then | |
print_error "Insufficient RAM. Required: ${MIN_RAM_MB}MB, Found: ${total_ram_mb}MB" | |
exit 1 | |
fi | |
if (( total_disk_gb < MIN_DISK_GB )); then | |
print_error "Insufficient disk space. Required: ${MIN_DISK_GB}GB, Found: ${total_disk_gb}GB" | |
exit 1 | |
fi | |
} | |
verify_security_settings() { | |
local failed=0 | |
# Check kernel parameters | |
local params=( | |
"kernel.unprivileged_bpf_disabled=1" | |
"net.ipv4.conf.all.log_martians=0" | |
"net.ipv4.ip_forward=1" | |
"fs.protected_hardlinks=1" | |
"fs.protected_symlinks=1" | |
) | |
for param in "${params[@]}"; do | |
local name=${param%=*} | |
local expected=${param#*=} | |
local actual=$(sysctl -n "$name" 2>/dev/null || echo "NOT_FOUND") | |
if [[ "$actual" != "$expected" ]]; then | |
print_error "Kernel parameter $name = $actual (expected $expected)" | |
failed=1 | |
fi | |
done | |
# Check Docker settings | |
if ! docker info 2>/dev/null | grep -q "Cgroup Driver: systemd"; then | |
print_error "Docker is not using systemd cgroup driver" | |
failed=1 | |
fi | |
if [[ "$(stat -c %a /var/run/docker.sock)" != "660" ]]; then | |
print_error "Docker socket has incorrect permissions" | |
failed=1 | |
fi | |
# Check services | |
local services=( | |
"docker" | |
"fail2ban" | |
"ufw" | |
"auditd" | |
"chrony" | |
) | |
for service in "${services[@]}"; do | |
if ! systemctl is-active --quiet "$service"; then | |
print_error "Service $service is not running" | |
failed=1 | |
fi | |
done | |
# Check AIDE database | |
if [ ! -f /var/lib/aide/aide.db ]; then | |
print_error "AIDE database not initialized" | |
failed=1 | |
fi | |
# Check Chrony sync | |
if ! chronyc tracking &>/dev/null; then | |
print_error "Chrony is not syncing time" | |
failed=1 | |
fi | |
# Additional security checks | |
if ! ufw status | grep -q "Status: active"; then | |
print_error "UFW firewall is not active" | |
failed=1 | |
fi | |
if ! grep -q "PasswordAuthentication no" /etc/ssh/sshd_config; then | |
print_error "SSH password authentication is not disabled" | |
failed=1 | |
fi | |
if ! apparmor_status | grep -q "apparmor module is loaded."; then | |
print_error "AppArmor is not loaded" | |
failed=1 | |
fi | |
return $failed | |
} | |
handle_error() { | |
local line_number=$1 | |
print_error "Script failed on line ${line_number}" | |
print_error "Please check the logs above for more information" | |
exit 1 | |
} | |
# Set up error handling | |
trap 'handle_error ${LINENO}' ERR | |
# --- Pre-flight Checks --- | |
print_message "${YELLOW}" "Performing pre-flight checks..." | |
check_root | |
check_os | |
check_resources | |
# --- System Updates --- | |
print_message "${YELLOW}" "Updating system packages..." | |
apt-get update | |
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y | |
# --- Essential Packages --- | |
print_message "${YELLOW}" "Installing essential packages..." | |
DEBIAN_FRONTEND=noninteractive apt-get install -y \ | |
ufw \ | |
fail2ban \ | |
curl \ | |
wget \ | |
gnupg \ | |
lsb-release \ | |
ca-certificates \ | |
apt-transport-https \ | |
software-properties-common \ | |
sysstat \ | |
auditd \ | |
audispd-plugins \ | |
unattended-upgrades \ | |
acl \ | |
apparmor \ | |
apparmor-utils \ | |
aide \ | |
rkhunter \ | |
logwatch \ | |
git \ | |
python3-pyinotify | |
# --- Time Synchronization --- | |
print_message "${YELLOW}" "Configuring time synchronization..." | |
systemctl stop systemd-timesyncd || true | |
systemctl disable systemd-timesyncd || true | |
apt-get remove -y systemd-timesyncd || true | |
DEBIAN_FRONTEND=noninteractive apt-get install -y chrony | |
if systemctl -q is-enabled systemd-timesyncd 2>/dev/null; then | |
systemctl disable systemd-timesyncd | |
systemctl stop systemd-timesyncd | |
fi | |
systemctl enable chrony.service || true # use .service to avoid alias issues | |
systemctl start chrony.service | |
# --- System Hardening --- | |
print_message "${YELLOW}" "Configuring system security..." | |
# Configure AppArmor | |
systemctl enable apparmor | |
systemctl start apparmor | |
# Initialize AIDE | |
aide --config=/etc/aide/aide.conf --init | |
mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db | |
# Configure kernel parameters | |
cat <<EOF > /etc/sysctl.d/99-security.conf | |
# Network security | |
net.ipv4.conf.all.send_redirects = 0 | |
net.ipv4.conf.default.send_redirects = 0 | |
net.ipv4.conf.all.accept_redirects = 0 | |
net.ipv4.conf.default.accept_redirects = 0 | |
net.ipv4.conf.all.rp_filter = 1 | |
net.ipv4.conf.default.rp_filter = 1 | |
net.ipv4.icmp_echo_ignore_broadcasts = 1 | |
net.ipv4.icmp_ignore_bogus_error_responses = 1 | |
net.ipv4.conf.all.accept_source_route = 0 | |
net.ipv4.conf.default.accept_source_route = 0 | |
net.ipv6.conf.all.accept_source_route = 0 | |
net.ipv6.conf.default.accept_source_route = 0 | |
net.ipv4.tcp_syncookies = 1 | |
net.ipv4.tcp_max_syn_backlog = 2048 | |
net.ipv4.tcp_synack_retries = 2 | |
net.ipv4.tcp_syn_retries = 5 | |
# Docker needs IPv4 forwarding | |
net.ipv4.ip_forward = 1 | |
# System limits | |
fs.file-max = 1048576 | |
kernel.pid_max = 65536 | |
net.ipv4.ip_local_port_range = 1024 65000 | |
net.ipv4.tcp_tw_reuse = 1 | |
vm.max_map_count = 262144 | |
kernel.kptr_restrict = 2 | |
kernel.dmesg_restrict = 1 | |
kernel.perf_event_paranoid = 3 | |
kernel.unprivileged_bpf_disabled = 1 | |
net.core.bpf_jit_harden = 2 | |
kernel.yama.ptrace_scope = 2 | |
# File system hardening | |
fs.protected_hardlinks = 1 | |
fs.protected_symlinks = 1 | |
fs.suid_dumpable = 0 | |
# Additional network hardening | |
net.ipv4.conf.all.log_martians = 0 | |
net.ipv4.conf.default.log_martians = 0 | |
net.ipv6.conf.all.accept_ra = 0 | |
net.ipv6.conf.default.accept_ra = 0 | |
EOF | |
sysctl -p /etc/sysctl.d/99-security.conf | |
sysctl --system # This loads all configs including the new one | |
# Configure system limits | |
cat <<EOF > /etc/security/limits.d/docker.conf | |
* soft nproc 10000 | |
* hard nproc 10000 | |
* soft nofile 1048576 | |
* hard nofile 1048576 | |
* soft core 0 | |
* hard core 0 | |
* soft stack 8192 | |
* hard stack 8192 | |
EOF | |
# --- Docker Installation --- | |
print_message "${YELLOW}" "Installing Docker..." | |
curl -fsSL https://get.docker.com -o get-docker.sh | |
sh get-docker.sh | |
rm get-docker.sh | |
# --- Docker Configuration --- | |
print_message "${YELLOW}" "Configuring Docker..." | |
mkdir -p /etc/docker | |
cat <<EOF > /etc/docker/daemon.json | |
{ | |
"log-driver": "json-file", | |
"log-opts": { | |
"max-size": "10m", | |
"max-file": "3" | |
}, | |
"icc": true, | |
"live-restore": true, | |
"userland-proxy": false, | |
"no-new-privileges": true, | |
"default-ulimits": { | |
"nofile": { | |
"Name": "nofile", | |
"Hard": 64000, | |
"Soft": 64000 | |
} | |
}, | |
"features": { | |
"buildkit": true | |
}, | |
"experimental": false, | |
"default-runtime": "runc", | |
"storage-driver": "overlay2", | |
"metrics-addr": "127.0.0.1:9323", | |
"builder": { | |
"gc": { | |
"enabled": true, | |
"defaultKeepStorage": "20GB" | |
} | |
} | |
} | |
EOF | |
# After Docker daemon.json configuration | |
print_message "${YELLOW}" "Testing Docker configuration..." | |
if ! docker info &>/dev/null; then | |
print_error "Docker failed to start. Checking configuration..." | |
journalctl -u docker.service --no-pager | tail -n 50 | |
exit 1 | |
fi | |
systemctl enable docker | |
systemctl restart docker || { | |
print_error "Docker failed to start. Logs:" | |
journalctl -u docker.service --no-pager | tail -n 50 | |
exit 1 | |
} | |
# Verify Docker configuration | |
print_message "${YELLOW}" "Verifying Docker configuration..." | |
docker info | grep -E "Cgroup Driver|Storage Driver|Logging Driver" | |
# --- User Setup --- | |
print_message "${YELLOW}" "Creating docker user..." | |
adduser --system --group --shell /bin/bash --home /home/docker --disabled-password docker | |
usermod -aG docker docker | |
# --- SSH Configuration --- | |
print_message "${YELLOW}" "Configuring SSH..." | |
mkdir -p /home/docker/.ssh | |
chown -R docker:docker /home/docker | |
chmod 755 /home/docker | |
# Copy root's authorized keys to docker user if they exist | |
if [ -f /root/.ssh/authorized_keys ]; then | |
cp /root/.ssh/authorized_keys /home/docker/.ssh/authorized_keys | |
chown -R docker:docker /home/docker/.ssh | |
chmod 700 /home/docker/.ssh | |
chmod 600 /home/docker/.ssh/authorized_keys | |
fi | |
cat <<EOF > /etc/ssh/sshd_config | |
Include /etc/ssh/sshd_config.d/*.conf | |
Port 22 | |
AddressFamily inet | |
Protocol 2 | |
HostKey /etc/ssh/ssh_host_ed25519_key | |
HostKey /etc/ssh/ssh_host_ecdsa_key | |
HostKey /etc/ssh/ssh_host_rsa_key | |
SyslogFacility AUTH | |
LogLevel VERBOSE | |
LoginGraceTime 30 | |
PermitRootLogin prohibit-password | |
StrictModes yes | |
MaxAuthTries 10 | |
MaxSessions 5 | |
PubkeyAuthentication yes | |
HostbasedAuthentication no | |
IgnoreRhosts yes | |
PasswordAuthentication no | |
PermitEmptyPasswords no | |
ChallengeResponseAuthentication no | |
UsePAM yes | |
AllowAgentForwarding no | |
AllowTcpForwarding yes # Required for Docker forwarding | |
X11Forwarding no | |
PermitTTY yes | |
PrintMotd no | |
ClientAliveInterval 300 | |
ClientAliveCountMax 2 | |
TCPKeepAlive no | |
AllowUsers docker root | |
KexAlgorithms curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256 | |
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr | |
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com | |
EOF | |
systemctl reload ssh | |
# --- Firewall Configuration --- | |
print_message "${YELLOW}" "Configuring firewall..." | |
ufw --force reset | |
ufw default deny incoming | |
ufw default allow outgoing | |
ufw allow ssh | |
ufw allow http | |
ufw allow https | |
ufw --force enable | |
# --- fail2ban Configuration --- | |
print_message "${YELLOW}" "Configuring fail2ban..." | |
cat <<EOF > /etc/fail2ban/filter.d/docker.conf | |
[Definition] | |
failregex = failed login attempt from <HOST> | |
ignoreregex = | |
EOF | |
cat <<EOF > /etc/fail2ban/jail.local | |
[DEFAULT] | |
bantime = 3600 | |
findtime = 600 | |
maxretry = 10 | |
banaction = ufw | |
banaction_allports = ufw | |
[sshd] | |
enabled = true | |
port = ssh | |
filter = sshd | |
logpath = /var/log/auth.log | |
maxretry = 10 | |
bantime = 3600 | |
[docker] | |
enabled = true | |
filter = docker | |
logpath = /var/log/auth.log | |
maxretry = 5 | |
bantime = 3600 | |
EOF | |
# --- Enable and Start Services --- | |
print_message "${YELLOW}" "Enabling services..." | |
systemctl enable docker fail2ban auditd chrony | |
systemctl restart docker fail2ban auditd chrony | |
# --- Verify Setup --- | |
print_message "${YELLOW}" "Verifying security settings..." | |
if verify_security_settings; then | |
print_success "Security verification passed" | |
else | |
print_warning "Some security checks failed. Please review the warnings above." | |
fi | |
# Add logging configuration | |
print_message "${YELLOW}" "Configuring system logging..." | |
cat <<EOF > /etc/logrotate.d/docker-logs | |
/var/lib/docker/containers/*/*.log { | |
rotate 7 | |
daily | |
compress | |
size=100M | |
missingok | |
delaycompress | |
copytruncate | |
} | |
EOF | |
# Automated cleanup to prevent residual files | |
print_message "${YELLOW}" "Setting up maintenance tasks..." | |
cat <<EOF > /etc/cron.daily/docker-cleanup | |
#!/bin/bash | |
docker system prune -af --volumes | |
docker builder prune -af --keep-storage=20GB | |
EOF | |
chmod +x /etc/cron.daily/docker-cleanup | |
# Configure auditd | |
cat <<EOF > /etc/audit/rules.d/audit.rules | |
# Docker daemon configuration | |
-w /usr/bin/dockerd -k docker | |
-w /var/lib/docker -k docker | |
-w /etc/docker -k docker | |
-w /usr/lib/systemd/system/docker.service -k docker | |
-w /etc/default/docker -k docker | |
-w /etc/docker/daemon.json -k docker | |
-w /usr/bin/docker -k docker-bin | |
EOF | |
# Reload audit rules | |
auditctl -R /etc/audit/rules.d/audit.rules | |
# Configure unattended-upgrades for automated security updates | |
print_message "${YELLOW}" "Configuring automatic updates..." | |
cat <<EOF > /etc/apt/apt.conf.d/50unattended-upgrades | |
Unattended-Upgrade::Allowed-Origins { | |
"\${distro_id}:\${distro_codename}-security"; | |
"\${distro_id}ESMApps:\${distro_codename}-apps-security"; | |
"\${distro_id}ESM:\${distro_codename}-infra-security"; | |
}; | |
Unattended-Upgrade::Remove-Unused-Dependencies "true"; | |
Unattended-Upgrade::Automatic-Reboot "false"; | |
EOF | |
# --- Final Cleanup --- | |
apt-get autoremove -y | |
apt-get clean | |
print_success "Setup complete! System hardening successful." | |
print_message "${YELLOW}" "Important next steps:" | |
print_message "${YELLOW}" "1. Add your SSH public key to /home/docker/.ssh/authorized_keys" | |
print_message "${YELLOW}" "2. Configure your deployment tools (Kamal, etc.)" | |
print_message "${YELLOW}" "3. REBOOT THE SYSTEM to apply all security settings" | |
print_message "${YELLOW}" "4. Review logs in /var/log/ regularly" | |
print_message "${YELLOW}" "5. Set up external monitoring and alerting" | |
# Additional verification info | |
print_message "${GREEN}" "System Information:" | |
echo "Docker Version: $(docker --version)" | |
echo "Kernel Version: $(uname -r)" | |
echo "AppArmor Status: $(aa-status --enabled && echo 'Enabled' || echo 'Disabled')" | |
echo "UFW Status: $(ufw status | grep Status)" | |
echo "fail2ban Status: $(fail2ban-client status | grep "Number of jail:")" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment