Server provisioning for a kamal setup

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:

  1. Have unattended upgrades on your server
  2. Lock down the root user
  3. Use a custom user for ssh and deployment
  4. Use fail2ban to ensure activity from suspicious IP addresses are blocked automatically 
  5. 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

Comments

No comments yet. Be the first to comment!
Your email address will be verified before your first comment is posted. It will not be displayed publicly.
Legal Information
By commenting, you agree to our Privacy Policy and Terms of Service.