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