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
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 |
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-upgradesstays 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.comconvenience script, which Docker doesn't recommend for production), with a production daemon config: bounded log files (100MB ร 3 per container, sokamal logshas history without eating the disk), andlive-restoreso containers keep running when the Docker daemon restarts during upgrades. - A key-only deploy user (
dockerby default) in thedockergroup, 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 withsshd -Tafter 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 andkexecdisabled), 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=10keeps 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:
- 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.
- If you add custom accessories with a
port:, bind them to loopback:port: "127.0.0.1:9200:9200", neverport: "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 prunecron. Kamal already prunes old containers and images automatically after every successful deploy, keeping the last 5 forkamal 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 yesis 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 withFailed to establish port forward. The script pinsGatewayPorts noinstead, 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.
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 upgradeduring maintenance windows โlive-restorekeeps your containers running through the daemon restart. - Check who's knocking:
fail2ban-client status sshdshows 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.localoverrides 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-railsfastand52unattended-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-serverfrom your machine, then re-run the script on the server. (Deliberate password-only bootstrap: re-run withALLOW_INCOMPLETE_SSH=true.) kamal deployfails withFailed to establish port forward: something turned offAllowTcpForwardingin 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.