Skip to content
RY

The Simplest Way to Deploy a Rails App on Hosts

Ram Laxman Yadav19 min read

Most deployment write-ups today start from a Dockerfile. This one doesn’t — because the server it’s written for doesn’t have Docker on it. Bosun is a tool for the deployment problem that never went away even as everything else moved to containers: a Rails app living directly on a dedicated or shared Ubuntu box, built by RVM, run by systemd, with more than one human needing to touch it.

If that sounds like 2014, that’s the point. Plenty of real production Rails apps still run exactly this way, and the tools built for that era — Capistrano and Mina — never solved the part that actually gets painful once a team, not a person, owns the server: who on that shared box gets to see the database password.

1. Introduction

Bosun is a .deb-packaged CLI for deploying Rails apps on Ubuntu hosts, built to work alongside a companion package called argus-tty, which manages tiered Linux accounts on the same host. It’s MIT licensed, and it deliberately does not assume a container runtime — the app runs as a normal OS process, managed by systemd, on a filesystem you can ssh into and look at.

The pipeline itself will look familiar if you’ve used Capistrano or Mina before: clone a release, bundle install, precompile assets, swap a symlink, restart services, roll back if something’s wrong. What’s different is everything around that pipeline — specifically, the access model for a server that several people need to deploy to without all of them having root, a personal SSH key on the git host, or the ability to read DATABASE_URL out of a shared .env file.

Note

Bosun doesn’t require argus-tty to run. Without it, Bosun falls back to a single-operator standalone mode — no tiers, one account doing everything. More on the trade-off that comes with that later.

2. The need for Bosun — and what it does that Capistrano and Mina don’t

I’ve written about Capistrano and Mina on this blog before — back then, the comparison was about deploy speed: Capistrano’s cap production deploy:cold taking minutes versus Mina compiling everything into one fast SSH session. That comparison still holds. But it’s also the wrong axis for the problem Bosun is solving.

Capistrano, Mina, and even newer tools like Kamal are all, at their core, deploy choreography tools. They answer “how do I get my code from git onto the server and have it running,” and they answer it well. None of them have an opinion about who on the server is allowed to do what. If three developers, a deployer, and an admin all SSH into the same box, Capistrano and Mina assume that’s a solved problem somewhere else — usually by just giving everyone the same SSH key and the same shell access to everything, including the .env file sitting in shared/.

That’s the gap Bosun targets. Its distinguishing capabilities, directly from how it’s built:

  • Tiered Linux accounts. Every Bosun subcommand checks the caller’s role — developer, deployer, or admin — against a table of what that tier is allowed to run. A developer can install and setup an app and open a console, but can’t deploy to production. A deployer can deploy, restart, and cleanup, but can’t add a new app or bootstrap the host. Capistrano and Mina have no concept of this at all — a cap deploy and a cap production console are gated by nothing but whether your key is in authorized_keys.
  • Secrets locked to one service account, not a shared group. shared/.env is root:root, mode 600, with a single named-user ACL grant for one service account (webapp-svc) — not the webapps group every developer sits in. Capistrano’s and Mina’s shared-directory convention puts .env-equivalent files wherever the group can read them, because group-writable shared directories are how both tools work.
  • One shared deploy key per app, not one per developer. Bosun generates a single ed25519 keypair per app and distributes copies only to current admin and deployer accounts — never to developers. Compare that to the common Capistrano/Mina setup, where every developer’s personal key ends up as a deploy key (or worse, everyone shares one key with no rotation story).
  • No new sudo privileges. Deploys reuse argus-tty’s existing sudoers whitelist. A custom deploy script can call anything a developer or admin could already call via sudo — it can’t escalate beyond that, which matters once you start letting people override the deploy script itself (more on this in section 8).
  • Full-script overrides instead of a hook DSL. Capistrano and Mina both let you customize a deploy, but only through their own hook/Rake-task system — you’re writing into their lifecycle. Bosun’s override, do-release, is a complete bash script you copy and edit directly; there’s no DSL to learn, and nothing stops you from replacing the entire deploy if one app genuinely needs it (its docs give swapping Sidekiq for a sneakers consumer as the worked example).

Kamal gets a mention here too, since it’s the modern answer to “how do I deploy without a PaaS.” Kamal assumes Docker end to end. If your target host can run containers, Kamal is almost certainly a better fit today. Bosun exists for the host that can’t or won’t — a shared box where Docker was never installed, or a dedicated server that predates the whole containers-everywhere shift.

Warning

None of this makes Bosun “more secure than Capistrano” in the abstract — it makes it suited to a problem Capistrano and Mina weren’t designed to solve: multiple humans, one shared host, real secrets. If you’re the only person who ever touches the server, this entire access model buys you nothing, and Bosun says so itself via standalone mode.

3. Architecture at a glance

Before installing anything, it’s worth knowing the shape of what gets created. Each app lives at /var/www/<app>, laid out the same way Capistrano users will recognize:

/var/www/myapp/
├── releases/ # one directory per deploy, timestamped (or named by RELEASE_VERSION_CMD)
├── shared/
│ ├── .env # root:root, 600 — ACL-readable only by webapp-svc
│ ├── deploy.conf # 660 — RUBY_VERSION, RUBY_GEMSET, PROJECT_REPO_URL, KEEP_LATEST_RELEASE
│ ├── deploy_key # root:root, 600 — the app's one ed25519 private key
│ ├── do-release # optional — overrides the built-in deploy script if present + executable
│ ├── config/ # database.yml, puma.rb, sidekiq.yml, sidekiq.rb, secret.yml
│ ├── bin/ # exec wrappers: puma-exec, sidekiq-exec, run, any custom-service-exec
│ └── log/ # puma.log, sidekiq.log, and any custom service's <name>.log
└── current -> releases/<latest>/

Two filesystem details make the whole access model work without constant permission fights:

  • Directories are created setgid (2775), so new files automatically inherit the webapps group instead of whoever’s umask happened to create them.
  • A default ACL — setfacl -d -m g:webapps:rwX — keeps that group-write behavior intact even though argus-tty sets a strict umask 027 host-wide.

On top of the filesystem, there’s the role table require_role() checks on every subcommand:

Command developer deployer admin
install, setup, run, logs ✅ ❌ ✅
deploy, cleanup, restart ❌ ✅ ✅
add, bootstrap root only root only root only
version, mode (view) ✅ ✅ ✅

4. Installing Bosun

Install order matters: argus-tty first, since it’s what creates the developer/admin Linux accounts, the sudoers whitelist, and the webapps/rvm groups Bosun relies on. Bosun installed without it just drops into standalone mode.

Option A — Launchpad PPA (recommended, gets you upgrades via normal apt upgrade):

Terminal window
sudo add-apt-repository ppa:yadavramlaxman/argus-tty
sudo add-apt-repository ppa:yadavramlaxman/bosun
sudo apt update
sudo apt install argus-tty bosun

Option B — standalone .deb packages:

Terminal window
sudo apt install ./argus-tty_1.0.0_all.deb
sudo apt install ./bosun_1.0.0_all.deb

Once both are installed, verify before you provision anything:

Terminal window
sudo argus-tty status # should report green
bosun version # confirms bosun can find its own helper scripts

If bosun version fails, the .deb didn’t place its files where Bosun expects — it sources argus-tty’s /usr/lib/argus-tty/lib/common.sh directly, so this is a real dependency, not a soft one.

One-time per host, run the bootstrap, which sets up multi-user RVM and wires existing accounts into the webapps/rvm groups:

Terminal window
sudo bosun bootstrap

Note

No argus-tty on the box? Bosun doesn’t refuse to run — it drops into standalone mode automatically: no tiers, one account doing everything. You can also pick a mode explicitly with sudo bosun mode standalone or sudo bosun mode argus-tty. Switching modes doesn’t touch an already-deployed app’s code or its .env — see section 10 for the trade-off this mode carries.

5. Setting up a new app, start to finish

This is the full sequence for taking a Rails app from “nothing on this server” to “deployed and running,” across the three roles involved.

Step 1 (admin) — provision the app:

Terminal window
sudo bosun add myapp

This creates the /var/www/myapp tree, the systemd units, the .env and deploy.conf skeletons, and a brand-new ed25519 deploy key — then prints the public half of it. Add that as a read-only deploy key on the app’s git repository.

If the developer who’ll be working on this app doesn’t have a Linux account yet:

Terminal window
sudo argus-tty add alice --name "Alice Kumar" --pubkey alice_id_ed25519.pub

Alice needs to log out and back in if she was already logged in, since group membership is session-scoped. Re-run sudo bosun add myapp afterward to backfill her copy of the deploy key — re-running add redistributes the existing key, it never regenerates it.

Step 2 (developer) — fill in deploy metadata:

Terminal window
ssh alice@host
vi /var/www/myapp/shared/deploy.conf

Set RUBY_VERSION, RUBY_GEMSET, PROJECT_REPO_URL, and KEEP_LATEST_RELEASE (how many old releases to retain for rollback/cleanup).

Step 3 (admin) — fill in app secrets:

Terminal window
sudo vi /var/www/myapp/shared/.env

Set DATABASE_URL, SECRET_KEY_BASE, RAILS_ENV, and anything else the app needs. The file is root-owned — only an admin account can open it for editing. Developers and deployers only ever get at it indirectly, through bosun run.

Step 4 (developer) — install Ruby and build dependencies:

Terminal window
bosun install myapp

This installs OS build packages via apt, then installs the exact Ruby version and gemset named in deploy.conf through RVM — no sudo needed for this part, since RVM-managed Rubies live under the rvm group. Confirm it matches what you expect:

Terminal window
bosun version myapp

Step 5 (developer) — set up the shared config tree:

Terminal window
git clone git@git.example.com:yourorg/myapp.git /tmp/myapp-config
cd /tmp/myapp-config
bosun setup myapp

This creates the shared directory structure and copies config/puma.rb and config/secret.yml out of the checkout (it warns, rather than fails, if secret.yml is missing — and it’s safe to re-run). Then fill in the pieces that are environment-specific:

Terminal window
vi /var/www/myapp/shared/config/database.yml
vi /var/www/myapp/shared/config/sidekiq.yml
vi /var/www/myapp/shared/config/sidekiq.rb

Step 6 (developer or deployer) — first deploy:

Terminal window
bosun deploy myapp main

Verify it’s actually up:

Terminal window
sudo systemctl status puma-myapp sidekiq-myapp
sudo journalctl -u puma-myapp -n 50

Day-to-day after this: every later deploy is just bosun deploy myapp <branch>. For a console or a migration, go through bosun run so shared/.env actually gets loaded into the session:

Terminal window
bosun run console myapp
bosun run db:migrate myapp

6. What actually happens during bosun deploy

The deploy pipeline, in order:

  1. Clone the target branch into a new timestamped (or commit-named, via RELEASE_VERSION_CMD) directory under releases/.
  2. Run bundle install against the Ruby/gemset pinned in deploy.conf.
  3. Run assets:precompile, briefly elevated to the webapp-svc account for this one step.
  4. Swap the current symlink to point at the new release.
  5. Restart the app’s services (Puma and Sidekiq, plus any custom service wired into the deploy script).
  6. Run a health check.
  7. If the health check fails, automatically roll back to the previous release and point current back at it.
  8. Clean up old releases, keeping however many KEEP_LATEST_RELEASE in deploy.conf specifies (you can also trigger this manually with bosun cleanup myapp).

Warning

Worth being upfront about: the exact mechanics of the health check and the rollback step aren’t published in Bosun’s docs beyond “a health check runs, and a failure rolls back automatically.” If your deploy depends on knowing precisely what’s being checked, read do-release.sample on the host itself (/usr/lib/bosun/deploy/do-release.sample) before you rely on it in anger.

7. Workers and systemd, in detail

bosun add generates two systemd unit files per app: /etc/systemd/system/puma-<app>.service and sidekiq-<app>.service. They’re enabled but not started at creation time, because current doesn’t exist yet — there’s nothing to run until the first bosun deploy.

Both units share the same shape. Based on Bosun’s documented settings, a generated unit looks like this:

/etc/systemd/system/puma-myapp.service
[Unit]
Description=Puma server for myapp
After=network.target
[Service]
Type=simple
User=webapp-svc
Group=webapps
WorkingDirectory=/var/www/myapp/current
EnvironmentFile=-/var/www/myapp/shared/.env
ExecStart=/var/www/myapp/shared/bin/puma-exec
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

A few details worth calling out explicitly:

  • The leading - on EnvironmentFile=-... makes the file optional — systemd won’t refuse to start the unit if .env happens to be missing, it just starts without those variables.
  • ExecStart points at an exec wrapper, not bundle exec puma directly. The wrapper (shared/bin/puma-exec, shared/bin/sidekiq-exec) resolves RUBY_VERSION@RUBY_GEMSET from deploy.conf at run time, so the unit file itself never needs editing when you bump a Ruby version — only the wrapper’s lookup changes behavior.
  • User=webapp-svc, not the deploying developer’s account. This is the same service account that holds the one ACL grant to read .env — nobody’s personal login runs the app process.

Socket activation is available if you want systemd (not Puma) owning the listening socket: remove the bind "unix://..." line from config/puma.rb, add Requires=puma-<app>.socket under [Unit] in the service file, then:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now puma-myapp.socket

The socket itself lives at shared/tmp/sockets/puma.sock — the same path nginx proxies to (section 11).

Logging — and why there’s no StandardOutput= or SyslogIdentifier= here

This is the part most people expect to configure and are surprised to find Bosun deliberately avoids. Neither unit sets StandardOutput= or StandardError=. That’s not an oversight — it’s working around a specific permission trap:

systemd opens any file named in StandardOutput=append:/path/to/log as root, before it drops privileges to User=webapp-svc. If Puma then tries to reopen that same file itself (which stdout_redirect in config/puma.rb does, for log rotation on USR1), it hits EACCES — a root-owned file a non-root service account can’t reopen for writing.

So the convention Bosun uses instead:

  • Puma logs itself, via stdout_redirect in its own config/puma.rb, straight to shared/log/puma.log, owned by webapp-svc from the start.
  • Anything the unit doesn’t explicitly redirect falls through to the journal by systemd’s default behavior — view it with:
    Terminal window
    sudo journalctl -u puma-myapp -n 50
    sudo journalctl -t puma-myapp -f
  • Every custom worker follows the same rule, enforced through its exec wrapper rather than a systemd directive or a process’s own -L flag (see section 9 — this matters because Sidekiq 6.0+ actually removed its own log-file flag, so for Sidekiq specifically there’s no “ask the process to log to a file” option left).

Note

One convention, one place to look: whatever the service, tail -f /var/www/myapp/shared/log/<name>.log gets you its application-level log, and journalctl -u <unit> gets you anything systemd itself captured (crashes, restarts, startup failures before the app’s own logger was even ready).

Restarting and checking status day to day:

Terminal window
sudo systemctl status puma-myapp sidekiq-myapp
sudo systemctl {start,stop,restart} puma-myapp
bosun restart myapp # restarts everything bosun manages for this app in one call

8. Overriding the deploy script for one app

Sometimes the built-in deploy — Puma + Sidekiq, nothing else — isn’t what one particular app needs. Bosun’s answer is a full script swap, not a hook:

Terminal window
cp /usr/lib/bosun/deploy/do-release.sample /var/www/myapp/shared/do-release
chmod +x /var/www/myapp/shared/do-release
vi /var/www/myapp/shared/do-release

From this point on, bosun deploy myapp <branch> runs shared/do-release instead of the built-in script — but only for this one app. Every other app on the host keeps using the default. If the file is missing or not executable, the deploy prints a non-blocking warning and silently falls back to the default Puma/Sidekiq behavior, so there’s no way to half-break this by accident.

The sample script’s comments walk through two concrete replacement scenarios: swapping Sidekiq for a sneakers (RabbitMQ) consumer, and swapping systemd service management for docker run entirely. A sane way to adopt this without surprises: deploy once with an unedited copy first — it behaves identically to the built-in default — then make your changes incrementally.

Warning

A do-release override is a real bash script, which means it can do anything the deploying account’s sudoers whitelist already allows — it is not sandboxed the way a hook callback would be. The flip side is also true: it cannot grant itself any sudo privilege beyond what argus-tty already whitelisted for that developer or admin tier, no matter what the script says.

9. Adding a third service (workers beyond Puma and Sidekiq)

bosun add only ever generates units for Puma and Sidekiq. A third long-running process — a RabbitMQ consumer, a scheduled worker, anything else — needs one of two paths.

Path 1: a hand-created systemd unit. An admin creates <name>-<app>.service, following the same shape as the generated Puma/Sidekiq units (service user, EnvironmentFile, WorkingDirectory=current, Restart=on-failure). Reload and enable it, but don’t start it yet:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable <name>-myapp.service

Then write the exec wrapper it points at — a small script in shared/bin/, which a developer can write without needing admin access again. The wrapper’s job:

  1. set -e and export the relevant variables from deploy.conf and .env.
  2. cd into current.
  3. Set up the RVM path explicitly, so RVM’s per-Ruby configuration doesn’t break under systemd’s minimal environment.
  4. End with exec-ing the Bundler-wrapped command, redirecting stdout/stderr by hand:
#!/usr/bin/env bash
set -euo pipefail
source /var/www/myapp/shared/deploy.conf
source /var/www/myapp/shared/.env
cd /var/www/myapp/current
export PATH="$HOME/.rvm/rubies/${RUBY_VERSION}/bin:$PATH"
exec bundle exec rake sneakers:run \
>> /var/www/myapp/shared/log/sneaker.log 2>&1

Fixed, non-secret settings — which queue to consume, worker concurrency — get exported directly in the wrapper rather than stuffed into .env, since .env is reserved for actual secrets.

Once the wrapper exists, start the service and tail its log to confirm it’s alive:

Terminal window
sudo systemctl start sneaker-myapp.service
tail -f /var/www/myapp/shared/log/sneaker.log

Path 2: Docker, for just this one process. No unit file, no admin step:

Terminal window
docker run -d \
--name sneaker-myapp \
--restart unless-stopped \
--env-file /var/www/myapp/shared/.env \
-v /var/www/myapp/current:/app \
your-sneakers-image

Wiring whichever path you picked into bosun deploy: copy do-release.sample (section 8) into shared/do-release, rename its worker-management functions and variables to match your new service, swap the systemctl calls for docker calls if you went with Path 2, then deploy once with no other changes to confirm it all starts cleanly before touching anything else.

10. Standalone mode — running without argus-tty

If /usr/lib/argus-tty/lib/common.sh isn’t present on the host, Bosun activates standalone mode on its own:

  • require_role() always passes — there are no tiers to check.
  • The service account becomes whichever account is running the command.
  • A bosun-apps group stands in for webapps.
  • The chosen account is recorded in /etc/bosun.conf, alongside BOSUN_MODE.
Terminal window
sudo bosun mode standalone
sudo bosun mode argus-tty
sudo bosun mode auto # let bosun decide based on whether argus-tty is installed

Warning

The documented trade-off, stated plainly in Bosun’s own docs: in standalone mode, the deploying account can read shared/.env directly. There’s no separate service account to isolate secrets from — you get the same deploy pipeline, but none of the access-control story from section 2. This is the right mode for a single-operator VPS; it is not a substitute for argus-tty on a shared box.

11. Fronting it with nginx

Bosun doesn’t manage nginx itself — that part is still on you, same as it would be with Capistrano or Mina. The documented reference setup proxies straight to the Puma socket:

upstream myapp {
server unix:/var/www/myapp/shared/tmp/sockets/puma.sock;
}
server {
listen 80;
server_name myapp.example.com;
location /assets/ { root /var/www/myapp/current/public; }
location /packs/ { root /var/www/myapp/current/public; }
location / {
proxy_pass http://myapp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

TLS termination is explicitly out of scope for Bosun — put Certbot or your load balancer in front of this the same way you would for any other Rails deploy.

12. Changing Ruby versions later

bosun install compares the RUBY_VERSION in deploy.conf against what’s recorded in shared/.ruby-version. On a version bump, it only removes the old Ruby if no other app’s deploy.conf still references it — because rvm uninstall takes every gemset under that Ruby version down with it, and this host can have several apps sharing one RVM install.

One thing this step doesn’t do for you: gemsets are per-Ruby-version, so after bosun install finishes the version swap, you still need bosun deploy to actually run bundle install under the new gemset before restarting services — installing the Ruby and installing your gems are two separate steps here, same as plain RVM.

Conclusion

Bosun isn’t trying to replace Capistrano, Mina, or Kamal in general — it’s answering a question none of them ask: on a server several people share, who’s allowed to see what, and who’s allowed to do what? If your deploy target is a dedicated or shared Ubuntu host, more than one person needs access at different trust levels, and handing everyone root or a personal deploy key feels like the wrong default, that’s exactly the gap Bosun fills.

If you’re deploying from a single account to your own VPS, or your target already runs containers, you’re probably better served by Mina, Capistrano, or Kamal respectively — Bosun’s own standalone mode admits as much. But for the “old-school dedicated box, new-school team” case, it’s one of the few tools that treats the access-control problem as seriously as the choreography problem.

Further reading: the full docs live at ramlaxmanyadav.github.io/bosun, including dedicated pages on permissions, provisioning, the secrets ACL model, and full-script overrides vs. hooks. The companion account-management layer, argus-tty, is documented separately at its GitHub repo.