DOCS LLMs

The server setup script โ€” hardening & maintenance

Every RailsFast server โ€” deploy targets and remote builders alike โ€” is initialized by one script: the RailsFast production server setup script, served at setup.railsfast.com (free & open source, MIT โ€” it lives in this gist).

It takes a fresh Ubuntu 24.04 LTS or 26.04 LTS VPS and turns it into a hardened, production-ready host for Kamal 2.x Docker deployments. This page documents what it does, the knobs it has, and how to maintain a server afterwards.

Running it

You normally never run it by hand โ€” the Quickstart passes it to the server via cloud-init on creation. To run it manually on a fresh server:

ssh root@your-server-ip
wget -qO railsfast-setup.sh https://setup.railsfast.com
less railsfast-setup.sh    # always read a script before you run it
bash railsfast-setup.sh
TIP

The script is idempotent: re-running it is always safe. It only changes what needs changing (it won't even restart Docker if the config is already right). Re-running is also how you upgrade a server that was set up with an older version of the script โ€” v3 removes the old version's footguns for you (see the changelog at the top of the script).

Optional knobs

Set these as environment variables before running (e.g. INSTALL_MOSH=true bash railsfast-setup.sh):

Variable Default What it does
DOCKER_USERNAME docker The deploy user Kamal connects as (ssh.user in deploy.yml)
SSH_PUBLIC_KEY (empty) Explicitly provision the deploy user's SSH key (otherwise it's copied from root's / the sudo user's authorized_keys)
INSTALL_MOSH false Installs mosh and opens UDP 60000:61000
INSTALL_AUDITD false Installs auditd with Docker-focused audit rules
SETUP_SWAP auto auto adds a swap file to every server that has none (2G, or 4G on โ‰ฅ4GB-RAM servers); true/false force it
SKIP_OS_CHECK false Run on untested Ubuntu versions, at your own risk
ALLOW_INCOMPLETE_SSH false Let the script finish on a server with no usable SSH key (normally that fails the run โ€” Kamal couldn't connect anyway). Even then, the canonical success line is withheld
NOTE

The script verifies everything it did at the end, and fails loudly: the ๐Ÿ‘ฝ SUCCESS: Setup complete! line only prints when every check passed โ€” including that the deploy user has a usable SSH key. On failure it exits non-zero โ€” under the cloud-init flow the server then does not reboot, and /var/log/cloud-init-output.log (or /var/log/railsfast-setup.log) shows exactly which check failed. The script is always safe to re-run after fixing the cause.

What it sets up

  • System updates + automatic security updates. Ubuntu's unattended-upgrades stays on its distro defaults (security patches install daily); the script only adds a couple of preferences in a drop-in. Kernel updates still need a reboot โ€” see maintenance below.
  • Docker Engine from Docker's official apt repository (not the get.docker.com convenience script, which Docker doesn't recommend for production), with a production daemon config: bounded log files (100MB ร— 3 per container, so kamal logs has history without eating the disk), and live-restore so containers keep running when the Docker daemon restarts during upgrades.
  • A key-only deploy user (docker by default) in the docker group, seeded with the same SSH key you gave your provider. Heads up: membership in the docker group is root-equivalent โ€” treat this user's key exactly like a root key.
  • SSH hardening via a drop-in at /etc/ssh/sshd_config.d/00-railsfast.conf: key-only logins, no passwords, no keyboard-interactive, root restricted to key-only as break-glass, verbose auth logging, and the effective configuration is verified with sshd -T after applying. The script never disables password login until an SSH key is actually installed, so a password-provisioned server can't lock you out โ€” it tells you how to finish hardening instead.
  • Firewall (ufw): deny incoming by default, allow only 22, 80, 443.
  • fail2ban watching SSH via the systemd journal, with incremental bans (repeat offenders get exponentially longer bans, up to a week).
  • Kernel hardening (/etc/sysctl.d/99-security.conf): spoofing/redirect protections for IPv4 and IPv6, SYN-flood resilience, restricted kernel introspection (kptr_restrict, dmesg_restrict, unprivileged eBPF and kexec disabled), filesystem link protections.
  • A bounded swap file on every server (2G, or 4G on โ‰ฅ4GB-RAM boxes, capped there). Kamal's gapless deploys briefly run the old and new app containers side by side, so the memory spike that invites the OOM killer recurs on every deploy โ€” swap absorbs it, and swappiness=10 keeps it an emergency valve rather than active memory. Servers set up before v3.0.1 gain swap the next time the script is re-run on them.
  • Time sync (keeps whichever of systemd-timesyncd/chrony the release ships โ€” Ubuntu 26.04 ships chrony) and a journald disk cap (500MB) so logs can't fill a small disk.

At the end it verifies everything (including that the deploy user can actually talk to Docker โ€” the one thing Kamal needs) and prints next steps.

The firewall truth every Docker host should know

Ports published by Docker containers bypass ufw โ€” Docker writes its own iptables rules ahead of ufw's (Docker docs). In the default RailsFast topology this is fine: only kamal-proxy publishes ports (80/443, which you want public); the postgres and pg-backup accessories publish nothing and are reachable only on the private kamal Docker network.

Two rules keep it that way:

  1. Add your provider's firewall as the outer layer (e.g. Hetzner Cloud Firewall allowing only 22, 80, 443). It filters before packets ever reach the server, so nothing Docker does can bypass it.
  2. If you add custom accessories with a port:, bind them to loopback: port: "127.0.0.1:9200:9200", never port: "9200:9200" โ€” as also noted in the Quickstart.

Things the script deliberately does NOT do

Knowing what's excluded (and why) matters as much as what's included:

  • No nightly docker system prune cron. Kamal already prunes old containers and images automatically after every successful deploy, keeping the last 5 for kamal rollback (prune docs). A blanket prune cron deletes exactly those retained versions and breaks rollback โ€” older versions of this script did this; v3 removes the cron when re-run on such servers.
  • No aide / rkhunter / logwatch. Intrusion-report tools are only useful if their reports reach a human โ€” which requires configuring outbound mail. Unconfigured, they're false confidence, and on Ubuntu they drag in a full mail server (postfix) and even a Ruby interpreter through package dependencies. Install them deliberately if you'll actually read their reports.
  • No automatic reboots. Unattended 4am reboots are a bigger availability risk than scheduled ones. The trade-off: kernel security updates aren't live until you reboot (see below).
  • No disabling of SSH TCP forwarding. AllowTcpForwarding yes is load-bearing for Kamal: the default RailsFast setup (registry.server: localhost:5555) pushes images through an SSH reverse tunnel (Kamal source). If you "harden" it off, deploys fail with Failed to establish port forward. The script pins GatewayPorts no instead, which keeps those tunnel listeners loopback-only.

Verify your server

The setup script ships with a companion acceptance test โ€” verify-server-setup.sh, published in the same gist. It re-checks every contract the setup makes (~40 read-only assertions: effective SSH config, post-quantum key exchange active, rollback-safe Docker pruning, deploy-user Docker access, firewall rules on the ports sshd actually listens on, fail2ban jail loaded, live kernel hardening values, no package bloat). It changes nothing โ€” it only reads.

Run it as root on a server that completed setup:

wget -qO verify-server.sh https://verify.railsfast.com
less verify-server.sh    # same rule as always: read scripts before running them
bash verify-server.sh

Expect RESULT: PASS=... FAIL=0 and the ALL GREEN verdict. Two expected deviations: servers upgraded in place from the old v2 script may show WARN lines about leftover packages (the setup script prints the removal recipe), and a server set up with ALLOW_INCOMPLETE_SSH=true will fail the SSH checks by design until you finish installing a key.

TIP

If you ever report a server issue, run this first and include its output โ€” it pinpoints which layer misbehaves in seconds.

Maintaining your server

Day-2 operations, in practice:

  • Security patches: applied automatically every day (userland). Kernel updates require a reboot โ€” when sshing in shows *** System restart required ***, reboot at a convenient time (sudo reboot; Kamal containers come back automatically). Prefer never rebooting? Ubuntu Pro livepatch is free for up to 5 machines.
  • Docker Engine updates: third-party repos are not auto-upgraded by unattended-upgrades, on purpose. Run apt update && apt upgrade during maintenance windows โ€” live-restore keeps your containers running through the daemon restart.
  • Check who's knocking: fail2ban-client status sshd shows failed SSH attempts and current bans (on any public IP, there will be plenty โ€” that's the internet, and it means the jail works).
  • Check the effective SSH config: sshd -T | grep -E 'passwordauthentication|permitrootlogin' โ€” this is what sshd actually runs with, resolved across all config files.
  • Where things live: SSH hardening in /etc/ssh/sshd_config.d/00-railsfast.conf ยท Docker daemon config in /etc/docker/daemon.json ยท fail2ban in /etc/fail2ban/jail.d/railsfast.conf (your own /etc/fail2ban/jail.local overrides it โ€” fail2ban's last-value-wins) ยท sysctls in /etc/sysctl.d/99-security.conf ยท auto-updates drop-ins in /etc/apt/apt.conf.d/21auto-upgrades-railsfast and 52unattended-upgrades-railsfast ยท script version marker in /etc/railsfast-setup.version (written only after verification passes).

Troubleshooting

  • Locked out? Use your provider's web console (e.g. Hetzner's "Console" button) โ€” it's a local terminal, not SSH, so SSH hardening doesn't apply to it.
  • Created the server with a root password instead of an SSH key? The script detects this, leaves password login enabled (disabling it would lock everyone out), and fails the run on purpose โ€” a server Kamal can't connect to isn't ready. Finish with: ssh-copy-id root@your-server from your machine, then re-run the script on the server. (Deliberate password-only bootstrap: re-run with ALLOW_INCOMPLETE_SSH=true.)
  • kamal deploy fails with Failed to establish port forward: something turned off AllowTcpForwarding in sshd โ€” re-run the setup script to restore the correct config.
  • A custom accessory is reachable from the internet even though ufw is active: you published its port without binding to loopback โ€” see the firewall truth above.