Skip to content

Instantly share code, notes, and snippets.

@rameerez
Last active January 17, 2025 20:55
Show Gist options
  • Save rameerez/238927b78f9108a71a77aed34208de11 to your computer and use it in GitHub Desktop.
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
#!/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