There are a number of things you want to do to secure your VPS before you install your application with Kamal. By default, Kamal only installs docker and a private network called kamal for you.
You want to do the following:
You want to do the following:
- Have unattended upgrades on your server
- Lock down the root user
- Use a custom user for ssh and deployment
- Use fail2ban to ensure activity from suspicious IP addresses are blocked automatically
- Use a firewall to prevent port scans and such
There is a lot to unpack so let's dive into it. I have taken great inspiration from Josef Strzibny's work with Kamal Handbook and Deployment from Scratch
This is the provisioning script I use to bootstrap a destination:
lang-ruby
#!/usr/bin/env ruby
# Provision a virtual private server for Kamal deployments on Ubuntu 22+ LTS.
#
# This script relies on SSH keys authentication.
#
# Usage: ./provision.rb <destination>
# Example: ./provision.rb production
#
# Make sure to add your private key first:
# % ssh-add ~/path/to/ssh/key
require "net/ssh"
require "kamal"
require "optparse"
require "yaml"
require "erb"
def parse_arguments
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: #{$0} <destination>"
opts.on("-h", "--help", "Show this help message") do
puts opts
exit
end
end.parse!
if ARGV.empty?
puts "Error: Destination argument is required"
puts "Usage: #{$0} <destination>"
puts "Example: #{$0} production"
exit 1
end
ARGV[0]
end
def validate_config_file(config_path)
unless File.exist?(config_path)
puts "Error: Base configuration file not found: #{config_path}"
puts "Make sure config/deploy.yml exists"
exit 1
end
end
def load_config(base_config_path, destination)
config_file = Pathname.new(base_config_path)
# Let Kamal handle the config loading and merging
Kamal::Configuration.create_from(
config_file: config_file,
destination: destination
)
end
def run_command(ssh, command, description)
puts "#{description}..."
result = ssh.exec!(command)
if result && result.strip != ""
puts "Output: #{result.strip}"
end
end
# Server setup commands
COMMANDS = {
install_essentials: {
description: "Installing essential packages",
command: <<~'SH'
apt update && apt install -y build-essential curl
SH
},
prepare_data: {
description: "Preparing storage for disk service",
command: <<~'SH'
mkdir -p /data/storage;
chmod 700 /data/storage;
chown 1000:1000 /data/storage
mkdir -p /data/redis;
chmod 700 /data/redis;
chown 1000:1000 /data/redis
mkdir -p /data/postgres;
chmod 700 /data/postgres;
chown 1000:1000 /data/postgres
SH
},
add_swap: {
description: "Adding swap space",
command: <<~'SH'
if [ ! -f /swapfile ]; then
fallocate -l 2GB /swapfile;
chmod 600 /swapfile;
mkswap /swapfile;
swapon /swapfile;
echo "\n/swapfile swap swap defaults 0 0\n" >> /etc/fstab;
sysctl vm.swappiness=20;
echo "\nvm.swappiness=20\n" >> /etc/sysctl.conf
else
echo "Swap file already exists"
fi
SH
},
install_fail2ban: {
description: "Installing and running fail2ban",
command: <<~'SH'
apt install -y fail2ban;
systemctl start fail2ban;
systemctl enable fail2ban
SH
},
configure_firewall: {
description: "Configuring firewall",
command: <<~'SH'
ufw logging on;
ufw default deny incoming;
ufw default allow outgoing;
ufw allow 22;
ufw allow 80;
ufw allow 443;
ufw --force enable;
systemctl restart ufw
SH
},
disable_root: {
description: "Disabling root",
command: <<~'SH'
sed -i 's@PasswordAuthentication yes@PasswordAuthentication no@g' /etc/ssh/sshd_config;
sed -i 's@PermitRootLogin yes@PermitRootLogin no@g' /etc/ssh/sshd_config;
chage -E 0 root;
systemctl restart ssh
SH
}
}
def add_user_command(user_name)
{
description: "Adding user with sudo privileges",
command: <<~SH
if ! id -u #{user_name} >/dev/null 2>&1; then
useradd --create-home #{user_name};
su - #{user_name} -c 'mkdir -p ~/.ssh';
su - #{user_name} -c 'touch ~/.ssh/authorized_keys';
cat /root/.ssh/authorized_keys >> /home/#{user_name}/.ssh/authorized_keys;
chmod 700 /home/#{user_name}/.ssh;
chmod 600 /home/#{user_name}/.ssh/authorized_keys;
echo '#{user_name} ALL=(ALL:ALL) NOPASSWD: ALL' | tee /etc/sudoers.d/#{user_name};
chmod 0440 /etc/sudoers.d/#{user_name};
visudo -c -f /etc/sudoers.d/#{user_name}
else
echo "User #{user_name} already exists"
fi
SH
}
end
def install_docker_command(user_name)
{
description: "Installing and configuring Docker",
command: <<~SH
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh;
systemctl enable docker;
systemctl start docker;
fi
if ! getent group docker >/dev/null; then
groupadd docker;
fi
if ! docker network ls | grep -q kamal; then
docker network create kamal;
fi
usermod -aG docker #{user_name}
SH
}
end
begin
destination = parse_arguments
# Get server IP and user name from config/deploy.yml
base_config_path = File.expand_path("config/deploy.yml")
validate_config_file(base_config_path)
config = load_config(base_config_path, destination)
hosts = config.roles.map(&:hosts).flatten + config.accessories.map(&:hosts).flatten
hosts.uniq!
user_name = config.ssh.user
hosts.each do |host|
puts "\nProvisioning server '#{host}' with user '#{user_name}'..."
Net::SSH.start(host, "root") do |ssh|
# Run base commands
COMMANDS.each do |_key, cmd|
run_command(ssh, cmd[:command], cmd[:description])
end
# Run user-specific commands
run_command(ssh, add_user_command(user_name)[:command], add_user_command(user_name)[:description])
run_command(ssh, install_docker_command(user_name)[:command], install_docker_command(user_name)[:description])
end
end
puts "\nDone!"
puts "Remember to log in as '#{user_name}' from now on:"
puts " ssh #{user_name}@#{hosts.first}"
rescue OptionParser::InvalidOption => e
puts "Error: #{e.message}"
exit 1
rescue Net::SSH::AuthenticationFailed
puts "Error: SSH authentication failed. Make sure your SSH key is added:"
puts " ssh-add ~/path/to/ssh/key"
exit 1
rescue StandardError => e
puts "Error: #{e.message}"
puts e.backtrace
exit 1
end
