---
title: "Deploy a Rails app"
description: "Deploy a Rails 8 app to an exe.dev VM: git push to deploy, HTTPS, SQLite and Solid Queue on a persistent disk, nightly backups, no Docker needed."
url: https://exe-staging.dev/deploy/rails
category: "Frameworks"
updated: 2026-10-17
---

# Deploy a Rails app

Your Rails app on a server of its own. git push, and it's live.

- **Software:** Ruby on Rails (https://rubyonrails.org)
- **Source:** https://github.com/rails/rails
- **License:** MIT
- **Time:** 10 minutes
- **Example VM name:** `my-rails` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/rails/manifest.json

## What you get

- Your app from GitHub (REPO), or a new Rails 8 app with a Notes scaffold, in production as `app.service`, with Thruster on port 8000 in front of Puma on localhost, enabled to come back after a reboot.
- A bare repository at `~/app.git` whose pre-receive hook deploys every push to main (bundle install, assets:precompile, a backup, db:prepare, restart, health check); a failed deploy rejects the push and the running version stays.
- SQLite databases and uploads in `~/data`, outside the code; settings and secrets (with `RAILS_MASTER_KEY` for a new app) in `~/app.env`, mode 600.
- For a new app, `config.assume_ssl` and `config.force_ssl` on, so forms, cookies and redirects work behind exe.dev's HTTPS.
- A nightly backup timer, and a backup in `~/backups` unpacked in a scratch directory whose main database passes an integrity check.

Running on the VM:

- **Thruster and Puma**: Rails 8's production server, run by systemd as app.service, port 8000
- **Ruby 4**: Installed with mise; your app's .ruby-version wins
- **SQLite**: The app's database, plus Solid Cache, Solid Queue and Solid Cable, data in `~/data`
- **Solid Queue**: Background and recurring jobs, inside Puma
- **git push deploys**: A bare repository whose hook builds, migrates and restarts, data in `~/app.git`
- **Nightly backups**: Consistent SQLite copies, uploads and app.env; two weeks kept, data in `~/backups`

How it is reached:

- https://my-rails.exe.xyz/ (private): Your app, until you make it public
- ssh my-rails.exe.xyz (private): `git push exe main`, a shell, logs and the Rails console

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Ruby%20on%20Rails%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Frails.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20Rails%20app%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-rails.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-rails.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Your%20app%20from%20GitHub%20%28REPO%29%2C%20or%20a%20new%20Rails%208%20app%20with%20a%20Notes%20scaffold%2C%20in%20production%20as%20%60app.service%60%2C%20with%20Thruster%20on%20port%208000%20in%20front%20of%20Puma%20on%20localhost%2C%20enabled%20to%20come%20back%20after%20a%20reboot.%0A-%20A%20bare%20repository%20at%20%60~%2Fapp.git%60%20whose%20pre-receive%20hook%20deploys%20every%20push%20to%20main%20%28bundle%20install%2C%20assets%3Aprecompile%2C%20a%20backup%2C%20db%3Aprepare%2C%20restart%2C%20health%20check%29%3B%20a%20failed%20deploy%20rejects%20the%20push%20and%20the%20running%20version%20stays.%0A-%20SQLite%20databases%20and%20uploads%20in%20%60~%2Fdata%60%2C%20outside%20the%20code%3B%20settings%20and%20secrets%20%28with%20%60RAILS_MASTER_KEY%60%20for%20a%20new%20app%29%20in%20%60~%2Fapp.env%60%2C%20mode%20600.%0A-%20For%20a%20new%20app%2C%20%60config.assume_ssl%60%20and%20%60config.force_ssl%60%20on%2C%20so%20forms%2C%20cookies%20and%20redirects%20work%20behind%20exe.dev%27s%20HTTPS.%0A-%20A%20nightly%20backup%20timer%2C%20and%20a%20backup%20in%20%60~%2Fbackups%60%20unpacked%20in%20a%20scratch%20directory%20whose%20main%20database%20passes%20an%20integrity%20check.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20follow%20%22Install%0Aby%20hand%22%20in%20order%2C%20skipping%20VM%20creation.%20The%20walkthrough%20above%20it%20is%20for%0Athe%20owner%20afterwards.%20Ask%20for%20REPO%20first%20if%20it%20says%20%3Cfill-this-in%3E.%0AFor%20REPO%3Dnew%2C%20follow%20%22Start%20a%20new%20app%22%20exactly.%20For%20a%20repository%2C%20follow%0A%22Bring%20your%20app%22%3A%20adapt%20~%2Fapp.env%2C%20apt%20packages%20and%20app.service%20to%20the%0Aapp%20as%20its%20checklist%20says%2C%20but%20never%20commit%20to%20the%20owner%27s%20code%2C%20in%0AGitHub%20or%20in%20~%2Fapp.git%2C%20so%20their%20first%20push%20fast-forwards.%20If%20a%20private%0Arepository%20can%27t%20be%20cloned%2C%20give%20the%20owner%20the%20%60integrations%20add%20github%60%0Acommand%20with%20this%20VM%27s%20name%20and%20wait%20for%20them.%0AKeep%20the%20deploy%20hook%20and%20backup%20script%20as%20written%2C%20since%20the%20page%0Adocuments%20how%20they%20behave%3B%20if%20one%20looks%20wrong%2C%20say%20so%20at%20the%20end.%0ADon%27t%20reboot%2C%20don%27t%20make%20the%20VM%20public%2C%20and%20don%27t%20run%20the%20Restore%20steps%3B%0Atest%20the%20backup%20in%20a%20scratch%20directory%20as%20written.%0AFinish%20with%20the%20app%27s%20URL%3B%20only%20the%20one%20git%20command%20from%20%60after%60%20that%0Afits%20%28%60git%20clone%20-o%20exe%60%20for%20REPO%3Dnew%2C%20%60git%20remote%20add%20exe%60%20for%20a%0Arepository%29%3B%20what%20you%20changed%20for%20their%20app%3B%20and%2C%20if%20it%20has%20a%0Aconfig%2Fcredentials.yml.enc%2C%20the%20command%20from%20%22Settings%20and%20secrets%22%20that%0Asends%20its%20master%20key.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20git%20clone%20-o%20exe%20my-rails.exe.xyz%3Aapp.git%20%20%20%20%23%20if%20you%20started%20fresh%0A%20%20git%20remote%20add%20exe%20my-rails.exe.xyz%3Aapp.git%20%20%23%20if%20you%20brought%20your%20app%3A%20in%20your%20checkout%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/rails/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
git clone -o exe my-rails.exe.xyz:app.git    # if you started fresh
git remote add exe my-rails.exe.xyz:app.git  # if you brought your app: in your checkout
```

Or follow the steps below by hand.

Rails 8 was built for one server with a real disk: SQLite in production,
Solid Queue for jobs, Solid Cache and Solid Cable on SQLite too, and
Thruster in front of Puma. An exe.dev VM is that server. This recipe puts
your app on a VM of its own: `git push exe main` deploys it, exe.dev gives
it HTTPS, its database lives on a persistent disk with nightly backups, and
it all comes back after a reboot.

Unlike a container platform, the disk stays put, so SQLite and uploads just
work; nothing sleeps or cold-starts; and you have SSH and an agent on the
box. The app is private until you share it.

## Open your app

When Shelley finishes, open the URL it gives you, like
`https://my-rails.exe.xyz/`. exe.dev asks you to sign in first: the app is
[private](/docs/sharing) until you open it up.

Started fresh? You'll see a small **Notes** app. Add a note: it's saved in
SQLite on the VM's disk, and it survives deploys, restarts and reboots.
Replace it with your own app whenever you like.

## Deploy with git push

The VM keeps a git repository, `~/app.git`, that deploys whatever you push
to its `main` branch, the way Heroku did.

On your computer, in your app's checkout, add the VM as a remote and push:

```bash
git remote add exe my-rails.exe.xyz:app.git
git push exe main
```

Started fresh? Clone the new app from the VM instead; its remote is
already called `exe`:

```bash
git clone -o exe my-rails.exe.xyz:app.git my-rails
cd my-rails
```

Each push prints the deploy as it happens: `bundle install`,
`assets:precompile`, a backup, `db:prepare` (which runs your migrations),
then a restart and a health check on `/up`. A good deploy ends with
`==> Deployed <sha>`. If any step fails, or the new version doesn't come
up, the hook puts the previous version back and Git rejects the push
(`! [remote rejected] main -> main (pre-receive hook declined)`), so
`main` on the VM is always what's running. Fix it and push again.

If your branch is called `master`, push it with `git push exe master:main`.
To roll back, push an older commit: `git push -f exe <good-sha>:main`.

> [!NOTE]
> GitHub stays your source of truth; `exe` is where you deploy. If
> `my-rails.exe.xyz` doesn't work as an SSH host for you, use
> `vm+my-rails@vm.exe.xyz:app.git`; see [SSH destinations](/docs/faq/ssh-destination).

## Settings and secrets

Production settings live in `~/app.env` on the VM, one `KEY=value` per line,
readable only by you. The app, the deploy hook and the backups all read it.

- `RAILS_MASTER_KEY` decrypts `config/credentials.yml.enc`. Rails never
  commits it, so it isn't in your repository.
- `SECRET_KEY_BASE` signs cookies and sessions. It was generated on the VM
  and takes precedence over the one in your credentials.
- `SOLID_QUEUE_IN_PUMA=1` runs your background jobs inside Puma.

Started fresh? Your app's master key is in `~/app.env`. To edit credentials
on your computer, copy it into your checkout (Git ignores the file):

```bash
ssh my-rails.exe.xyz "sed -n 's/^RAILS_MASTER_KEY=//p' app.env" > config/master.key
```

Brought your own app? Send it your master key from your checkout:

```bash
printf 'RAILS_MASTER_KEY=%s\n' "$(cat config/master.key)" \
  | ssh my-rails.exe.xyz 'cat >> app.env && sudo systemctl restart app'
```

To change a setting, edit the file on the VM and restart (and restart
`app-worker` too, if you [gave jobs their own service](#background-jobs)):

```bash
nano ~/app.env
sudo systemctl restart app
```

For a Rails console, a migration status or a rake task, load the same
environment first. On the VM:

```bash
cd ~/app && set -a && . ~/app.env && set +a
bin/rails console
```

## Your data and backups

Everything the app writes lives in `~/data`: the SQLite databases
(`production.sqlite3`, and the `_cache`, `_queue` and `_cable` databases
for Solid Cache, Solid Queue and Solid Cable) and Active Storage uploads.
Each deploy links the app's `storage/` directory there, so code and data
never mix.

Every night, and before every deploy, a script copies each database with
SQLite's online backup, which is safe while the app writes (copying a live
database file with `cp` can catch it half-written), and packs the copies
with your uploads and `app.env` into `~/backups`. The 14 newest of each
kind are kept. Keep copies **off the VM**, too. On your computer:

```bash
scp -r my-rails.exe.xyz:backups .
```

The backups contain your master key: store them like a password. To restore
one, see [Back up and restore](#back-up-and-restore).

## Background jobs

Solid Queue runs inside Puma (`SOLID_QUEUE_IN_PUMA=1`), as Rails 8 sets it
up for a single server: one service, one log. Recurring jobs go in
`config/recurring.yml`, so you don't need cron.

When jobs get heavy enough to slow down web requests, give them their own
service, so they restart and log separately. On the VM:

```bash
sudo sed 's|^ExecStart=.*|ExecStart=/home/exedev/app/bin/jobs|; s|^Description=.*|Description=Solid Queue for the Rails app|' \
  /etc/systemd/system/app.service | sudo tee /etc/systemd/system/app-worker.service >/dev/null
sed -i '/^SOLID_QUEUE_IN_PUMA=/d' ~/app.env
sudo systemctl daemon-reload
sudo systemctl restart app
sudo systemctl enable --now app-worker
sleep 5; journalctl -u app-worker -n 20 --no-pager -o cat
```

The log should show Solid Queue's supervisor and workers starting. The
deploy hook restarts `app-worker` too once it's enabled; after you edit
`~/app.env`, restart both: `sudo systemctl restart app app-worker`.

## Sign in with exe.dev

exe.dev's proxy only lets in people you've [shared](/docs/sharing) the app
with, and tells your app who they are: signed-in requests carry an
`X-ExeDev-Email` header ([Login with exe](/docs/login-with-exe)). For an
internal tool, that can be all the authentication you need. Add a concern:

```ruby
# app/controllers/concerns/exe_dev_login.rb
#
# Use exe.dev's login: its proxy, the only way in from the internet,
# adds X-ExeDev-Email to requests from signed-in visitors.
module ExeDevLogin
  extend ActiveSupport::Concern

  included do
    before_action :require_exe_dev_login
    helper_method :current_email
  end

  private
    def current_email
      request.headers["X-ExeDev-Email"].presence
    end

    def require_exe_dev_login
      return if current_email
      redirect_to "/__exe.dev/login?redirect=#{CGI.escape(request.fullpath)}"
    end
end
```

Then `include ExeDevLogin` in `ApplicationController`, and use
`current_email` in controllers and views (`Signed in as <%= current_email %>`).
To keep your own users table, look them up by it:
`User.find_or_create_by!(email_address: current_email)`. Requests to a
private app always have the header. A public app has it only for visitors
who signed in at `https://my-rails.exe.xyz/__exe.dev/login`, which is
where the concern sends everyone else; for pages anyone may see, add
`skip_before_action :require_exe_dev_login` to their controller.

## Make it public, add a domain

On your computer:

```bash
ssh exe.dev share set-public my-rails
```

To use your own domain, point a CNAME at `my-rails.exe.xyz` and register it
with `ssh exe.dev domain add my-rails app.example.com`; see
[custom domains](/docs/cnames). Rails needs no change: its production
defaults accept any host, and exe.dev only forwards your VM's own name and
domains you've registered. If your app sets `config.hosts`, add
`"my-rails.exe.xyz"` and your domain to it, and exclude `/up` as the
comment in `config/environments/production.rb` shows.

## Let Shelley help

Shelley, the agent on your VM, can read the logs, run the console and fix
things. Open it from the VM's page on exe.dev and ask, for example: "The
last deploy broke sign-up. Look at `journalctl -u app` and tell me why." To
have it change the app itself, ask it to work in a clone of the repository
(`git clone ~/app.git ~/src`) and deploy with `git push origin main`, so
your own checkout can pull its commits.

## The nuts and bolts

Happy clicking Deploy? You can stop here. These are the setup steps
Shelley follows, plus the maintenance commands for later.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create the VM

On your computer (pick another name if `my-rails` is taken):

```bash
ssh exe.dev new --name=my-rails
ssh my-rails.exe.xyz
```

All remaining installation commands run **on the VM**, as `exedev`.

### Install Ruby

Rails' own [install guide](https://guides.rubyonrails.org/install_ruby_on_rails.html)
recommends [mise](https://mise.jdx.dev/lang/ruby.html) for Ubuntu, and it
suits a server too: it downloads a precompiled Ruby in seconds instead of
compiling one for ten minutes, and it reads your app's `.ruby-version`, so
each deploy installs whichever Ruby the app asks for. (Ubuntu's own Ruby
is 3.2, too old for Rails 8.1.)

Install the libraries Rails' production Dockerfile uses (jemalloc for
memory, libvips for image variants, SQLite, build tools for gems with
native extensions), then mise and Ruby 4.0:

```bash
sudo apt-get update
sudo apt-get install -y --no-install-recommends build-essential git pkg-config \
  libyaml-dev libssl-dev libffi-dev zlib1g-dev libjemalloc2 libvips42t64 sqlite3
curl -fsSL https://mise.run | sh
echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc
echo 'eval "$(~/.local/bin/mise activate bash --shims)"' >> ~/.profile
export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"
mise settings add idiomatic_version_file_enable_tools ruby
mise use -g ruby@4.0
ruby -v
```

Expect `ruby 4.0.x` (Rails turns on YJIT itself). The `.bashrc` line
sets up mise in your shells; the `.profile` line puts Ruby on the `PATH`
of non-interactive logins too. There's no Node.js on the VM, and a
Rails 8 app with its defaults (importmap and Propshaft) doesn't need any.

### Create app.env

The app's production settings and secrets, outside the code. This only
creates the file if it doesn't exist yet: `SECRET_KEY_BASE` must stay the
same, or everyone gets signed out.

```bash
mkdir -p ~/data ~/releases ~/backups ~/bin
if [ ! -e ~/app.env ]; then
  install -m 600 /dev/null ~/app.env
  cat >> ~/app.env <<EOF
# Settings and secrets for the Rails app: KEY=value, one per line.
# Read by app.service, the deploy hook and the backups. After a change:
#   sudo systemctl restart app
RAILS_ENV=production
SECRET_KEY_BASE=$(openssl rand -hex 64)
SOLID_QUEUE_IN_PUMA=1
BUNDLE_WITHOUT=development:test
BUNDLE_FROZEN=true
# Apps from before Rails 7.1 need these two; newer ones ignore them.
RAILS_LOG_TO_STDOUT=1
RAILS_SERVE_STATIC_FILES=1
EOF
fi
stat -c '%a %n' ~/app.env
```

Expect `600 /home/exedev/app.env`.

### Back up nightly

This script copies each SQLite database in `~/data` with `sqlite3
.backup`, which takes a consistent snapshot even while Puma and Solid
Queue write to it. (A plain `cp` is not safe: in WAL mode, the newest
writes sit in a separate `-wal` file, and a copy taken mid-write can be
corrupt.) It checks each copy, adds the rest of `~/data` (uploads) and
`~/app.env`, and keeps the 14 newest backups of each kind:

```bash
cat > ~/bin/app-backup <<'EOF'
#!/usr/bin/env bash
# Back up ~/data and ~/app.env to ~/backups/app-<kind>-<time>.tar.gz.
# SQLite databases (*.sqlite3) are copied with .backup, safe while the
# app runs, and checked. Keeps the 14 newest backups of each kind.
# Usage: app-backup [nightly|deploy|manual]
set -euo pipefail
umask 077
kind=${1:-manual}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$HOME/backups" "$work/data"
cd "$HOME/data"
tar --exclude='*.sqlite3' --exclude='*.sqlite3-wal' --exclude='*.sqlite3-shm' -cf - . | tar -xf - -C "$work/data"
find . -name '*.sqlite3' -type f | while read -r db; do
  mkdir -p "$work/data/$(dirname "$db")"
  sqlite3 "$db" ".backup '$work/data/$db'"
  [ "$(sqlite3 "$work/data/$db" 'PRAGMA quick_check')" = ok ]
done
cp "$HOME/app.env" "$work/"
cat "$HOME/app/REVISION" > "$work/REVISION" 2>/dev/null || true
out="$HOME/backups/app-$kind-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$out.part" -C "$work" .
mv "$out.part" "$out"
ls -1t "$HOME/backups/app-$kind-"*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/bin/app-backup
```

Run it every night at about 03:30 UTC:

```bash
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up ~/data and ~/app.env to ~/backups

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/bin/app-backup nightly
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the app every night

[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer
systemctl list-timers app-backup.timer
```

The timer should show its next run.

### Run the app with systemd

`app.service` runs Thruster on port 8000, which exe.dev's
[HTTPS proxy](/docs/proxy) forwards to. Thruster, which Rails 8 apps
include, compresses responses, caches assets and passes requests to Puma,
which listens on localhost port 3000 only. systemd restarts the app if it
crashes and starts it at boot.

```bash
sudo tee /etc/systemd/system/app.service >/dev/null <<'EOF'
[Unit]
Description=Rails app (Thruster on port 8000, Puma on 127.0.0.1:3000)
After=network-online.target
Wants=network-online.target

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=PATH=/home/exedev/.local/share/mise/shims:/home/exedev/.local/bin:/usr/local/bin:/usr/bin:/bin
Environment=HTTP_PORT=8000 TARGET_PORT=3000 BINDING=127.0.0.1
Environment=LD_PRELOAD=libjemalloc.so.2
ExecStart=/home/exedev/app/bin/thrust bin/rails server
Restart=always
RestartSec=2
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app.service
```

It starts with the first deploy.

### Add the deploy hook

`~/app.git` is the repository you push to. Its `pre-receive` hook builds
each push in a new directory under `~/releases`, then points `~/app` at it
and restarts the app, so the running version is untouched until the new
one is ready. Before migrating, it backs up the databases. If anything
fails, it rejects the push, so `main` stays at the running commit.

```bash
git init --bare -b main ~/app.git
cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/usr/bin/env bash
# Deploys the app. Git runs this on every push to ~/app.git, before it
# updates main. To redeploy by hand (after editing ~/app.env, say):
#   ~/app.git/hooks/pre-receive HEAD
# Builds the commit in a new ~/releases/<time>-<commit> (gems, assets),
# backs up the databases, runs db:prepare (migrations), points ~/app at
# the release and restarts app.service. If a step fails, or the new
# version doesn't answer, the previous version stays (or comes back) and
# the push is rejected.
set -Eeuo pipefail
export PATH=$HOME/.local/share/mise/shims:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin
export GIT_DIR=$HOME/app.git
branch=$(git symbolic-ref --short HEAD)  # the branch that deploys
if [ $# -gt 0 ]; then
  rev=$(git rev-parse --verify "$1^{commit}")
else
  rev=
  while read -r _ new ref; do
    if [ "$ref" = "refs/heads/$branch" ]; then rev=$new; fi
  done
  if [ -z "$rev" ]; then echo "Only pushes to $branch deploy."; exit 0; fi
  case $rev in *[!0]*) ;; *) echo "Refusing to delete $branch."; exit 1 ;; esac
fi

exec 9>"$HOME/.deploy.lock" && flock 9  # one deploy at a time
release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${rev:0:7}
previous=$(readlink "$HOME/app" || true)
trap 'echo "==> Deploy FAILED. Still running: ${previous:-nothing}"; rm -rf "$release"' ERR

echo "==> Building ${rev:0:7} in $release"
mkdir -p "$release"
git archive "$rev" | tar -x -C "$release"
# Leave git's push environment (its quarantined objects) behind.
unset GIT_DIR GIT_QUARANTINE_PATH GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
cd "$release"
echo "$rev" > REVISION
rm -rf storage && ln -s "$HOME/data" storage  # databases and uploads
set -a; . "$HOME/app.env"; set +a
mise install  # the Ruby in .ruby-version, if it's new
bundle install
if grep -qE '^    (propshaft|sprockets) ' Gemfile.lock; then
  SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
fi
if [ -n "$(find "$HOME/data" -name '*.sqlite3' -print -quit)" ]; then
  "$HOME/bin/app-backup" deploy
fi
bin/rails db:prepare  # creates the databases, or runs pending migrations

echo "==> Starting the new version"
restart() {
  sudo systemctl restart app
  if systemctl -q is-enabled app-worker 2>/dev/null; then sudo systemctl restart app-worker; fi
}
ln -sfn "$release" "$HOME/app.new"
mv -T "$HOME/app.new" "$HOME/app"
trap - ERR  # from here on, a failure rolls back below
restart || true
# Wait for an answer on /up. Anything but none or a 5xx means Rails is
# up: an app without the /up route answers 404, and that's fine.
for _ in $(seq 60); do
  code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' http://localhost:8000/up || true)
  case $code in 000|5??) sleep 1 ;; *) break ;; esac
done
case $code in
  000|5??)
    journalctl -u app -n 30 --no-pager -o cat
    echo "==> Deploy FAILED: the new version answers HTTP $code on /up. Rolling back."
    if [ -n "$previous" ]; then
      ln -sfn "$previous" "$HOME/app.new"
      mv -T "$HOME/app.new" "$HOME/app"
      restart || echo "==> The previous version didn't restart either: journalctl -u app"
      rm -rf "$release"
    fi
    exit 1 ;;
esac
ls -1d "$HOME"/releases/*/ | head -n -3 | xargs -r rm -rf  # keep 3
echo "==> Deployed ${rev:0:7}: https://$(hostname).exe.xyz/"
EOF
chmod +x ~/app.git/hooks/pre-receive
```

Now follow **one** of the next two steps: start a new app, or bring yours.

### Start a new app

Generate an app with Rails' defaults, add a small Notes scaffold so
there's something to see, and turn on Rails' SSL settings, which it
generates commented out: `assume_ssl`, because TLS ends at exe.dev's
proxy, and `force_ssl`, for Secure cookies and HSTS (`/up` stays plain
HTTP for the deploy's health check). This takes a minute or two:

```bash
gem install rails -v '~> 8.1' --no-document
name=$(hostname)
cd /tmp && rm -rf "$name" && rails new "$name" && cd "$name"
bin/rails generate scaffold Note title:string body:text
bin/rails db:migrate
sed -i 's|^  # root "posts#index"|  root "notes#index"|' config/routes.rb
sed -i -e 's/# config.assume_ssl = true/config.assume_ssl = true/' \
  -e 's/# config.force_ssl = true/config.force_ssl = true/' \
  -e 's/# config.ssl_options = /config.ssl_options = /' config/environments/production.rb
grep -E '^ *(root|config\.(assume_ssl|force_ssl|ssl_options))' config/routes.rb config/environments/production.rb
```

The last command should print the `root` route and the three SSL lines.
Move the master key into `app.env` (it's never committed), commit, and
push to the deploy repository, which runs the hook:

```bash
cd /tmp/$(hostname)
echo "RAILS_MASTER_KEY=$(cat config/master.key)" >> ~/app.env
rm config/master.key
git ls-files -z --others --exclude-standard | xargs -0 git add --  # all but .gitignore'd
git -c user.name="exe.dev" -c user.email="exedev@$(hostname).exe.xyz" commit -q -m "New Rails app"
git push ~/app.git HEAD:main && cd ~ && rm -rf /tmp/$(hostname)
```

The output should end with `==> Deployed`.

### Bring your app

Clone it, with your repository in place of `owner/name`. A public
repository clones from GitHub; a private one through exe.dev's
[GitHub integration](/docs/integrations-github), with no token on the VM:

```bash
REPO=owner/name
slug=${REPO#https://github.com/}; slug=${slug%.git}
rm -rf /tmp/app-seed
GIT_TERMINAL_PROMPT=0 git clone "https://github.com/$slug.git" /tmp/app-seed ||
  git clone "https://github.int.exe.xyz/$slug.git" /tmp/app-seed
```

If both fail, the repository is private and the VM can't reach it yet.
On your computer, connect it (once per repository), then run the clone
again:

```bash
ssh exe.dev integrations add github --name my-app --repository owner/name --attach vm:my-rails
```

Check what your app needs before the first deploy. Change the VM, not the
code: these all go in `~/app.env`, apt packages or systemd.

- **Ruby:** nothing to do; each deploy installs the version in
  `.ruby-version` (or `.tool-versions`).
- **Thruster:** Rails 8 apps have `bin/thrust`. If yours doesn't, change
  how `app.service` starts, with the commands after this list.
- **SQLite outside `storage/`:** `storage/` already lands in `~/data`. If
  `production:` in `config/database.yml` puts a database anywhere else,
  point it into `~/data` in `~/app.env`, or it's lost on the next deploy:
  `DATABASE_URL=sqlite3:/home/exedev/data/production.sqlite3` for the
  primary database, and `QUEUE_DATABASE_URL`, `CACHE_DATABASE_URL` or
  `CABLE_DATABASE_URL` (`<NAME>_DATABASE_URL`, one per name under
  `production:`) for the others.
- **PostgreSQL:** [run it next to the app](/deploy/postgres#run-it-next-to-your-app),
  which puts its `DATABASE_URL` in `~/app.env`, and
  `sudo apt-get install -y libpq-dev` for the `pg` gem. **MySQL:**
  `sudo apt-get install -y mysql-server libmysqlclient-dev`, a database
  and user for the app, and its `DATABASE_URL` in `~/app.env`.
- **Redis** (Sidekiq, Resque, Action Cable on Redis):
  `sudo apt-get install -y redis-server`, and
  `REDIS_URL=redis://localhost:6379/0` in `~/app.env`. Run Sidekiq as
  `app-worker.service` (see [Background jobs](#background-jobs)) with
  `ExecStart=/home/exedev/.local/share/mise/shims/bundle exec sidekiq`.
- **Node.js** (a `package.json`, as jsbundling-rails and cssbundling-rails
  use): if `.tool-versions` or `mise.toml` lists `node` (and `yarn`), each
  deploy installs them too. If not, `mise use -g node@24`, plus
  `npm install -g yarn` if there's a `yarn.lock`. `assets:precompile` runs
  the JavaScript and CSS build.
- **SSL:** if `config/environments/production.rb` leaves
  `config.assume_ssl` and `config.force_ssl` commented out, the app still
  works (Rails trusts exe.dev's `X-Forwarded-Proto: https`), but its
  cookies aren't marked Secure. Turning both on is a change to your code:
  make it in your checkout and push.
- **Credentials:** if there's a `config/credentials.yml.enc`, the app
  probably needs its master key. Without it, the app still boots with the
  generated `SECRET_KEY_BASE`; send the key from your computer afterwards
  (see [Settings and secrets](#settings-and-secrets)).
- **Procfile:** a `release:` line is usually `db:migrate`, which the deploy
  does; a `worker:` line becomes `app-worker.service`.

No `bin/thrust`? If `Gemfile.lock` has the `thruster` gem, start it
through Bundler; without Thruster at all, let Puma listen on port 8000
itself (the commented line). The empty `ExecStart=` clears the original:

```bash
sudo mkdir -p /etc/systemd/system/app.service.d
sudo tee /etc/systemd/system/app.service.d/exec.conf >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=/home/exedev/.local/share/mise/shims/bundle exec thrust bin/rails server
# Without Thruster, use this line instead:
# ExecStart=/home/exedev/app/bin/rails server -b 0.0.0.0 -p 8000
EOF
sudo systemctl daemon-reload
```

Then push it to the deploy repository, which runs the hook:

```bash
cd /tmp/app-seed
git push ~/app.git HEAD:main && cd ~ && rm -rf /tmp/app-seed
```

The output should end with `==> Deployed`. Your app's history is now in
`~/app.git`, so your first `git push exe main` is an update.

### Check the app

```bash
systemctl is-enabled app
systemctl is-active app
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/up
readlink ~/app
ls -l ~/app/storage
ls ~/data
```

Expect `enabled`, `active`, `200`, a release under `~/releases`, the
`storage` link pointing to `/home/exedev/data`, and the databases there
(`production.sqlite3`, `production_cache.sqlite3` and so on, for a new
app).

### Test a backup

Take a backup now, unpack it in a scratch directory, and check its main
database:

```bash
latest=$(~/bin/app-backup manual)
rm -rf /tmp/restore-check && mkdir /tmp/restore-check
tar -xzf "$latest" -C /tmp/restore-check
find /tmp/restore-check -name '*.sqlite3' | sort
db=$(find /tmp/restore-check/data -name '*production.sqlite3' | head -n 1)
sqlite3 "$db" 'PRAGMA integrity_check;' 'SELECT count(*) FROM schema_migrations;'
rm -rf /tmp/restore-check
```

Expect the list of databases, `ok`, and the number of migrations.
Installation is done; continue with [Open your app](#open-your-app).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

On the VM, `~/bin/app-backup` makes a backup now and prints its path.
Each archive holds `data/` (each SQLite database as a consistent copy,
and uploads), `app.env`, and `REVISION`, the commit that was running.
Besides the nightly ones, every deploy that runs migrations takes an
`app-deploy-*` backup first.

### Restore

Pick a backup in `~/backups` and use its name below. On a new VM, first
follow [Install by hand](#create-the-vm) up to your first deploy, then
copy the archive over from your computer:

```bash
scp app-nightly-20261017-033012.tar.gz my-rails.exe.xyz:backups/
```

On the VM, stop the app, set the current data aside, and unpack:

```bash
backup=~/backups/app-nightly-20261017-033012.tar.gz
sudo systemctl stop app app-worker 2>/dev/null || true
mv ~/data ~/data.before-restore-$(date +%s)
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf "$backup" -C /tmp/restore
mv /tmp/restore/data ~/data
diff /tmp/restore/app.env ~/app.env
```

If `diff` shows settings or secrets you need from the backup (on a new
VM you do, at least `RAILS_MASTER_KEY` and `SECRET_KEY_BASE`), copy them:
`install -m 600 /tmp/restore/app.env ~/app.env`. Then bring the schema up
to the code's and start again:

```bash
cd ~/app && set -a && . ~/app.env && set +a
bin/rails db:prepare
sudo systemctl start app
systemctl -q is-enabled app-worker 2>/dev/null && sudo systemctl start app-worker
sleep 5; curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/up
rm -rf /tmp/restore
```

Expect `200`, and your data in the app. Delete `~/data.before-restore-*`
once you're happy.

</details>

<details id="update" class="dp-details">
<summary>Update Rails, Ruby and the VM</summary>

Updates to the app travel through Git like any other change:

- **Rails and gems:** in your checkout, `bundle update rails` (and
  `bin/rails app:update` for a new minor version; read the
  [upgrade guide](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html)),
  run your tests, commit, and `git push exe main`.
- **Ruby:** change `.ruby-version` (and `ruby` in the `Gemfile`, if it's
  there), commit and push. The deploy installs the new Ruby and its gems
  before switching. Apps without a `.ruby-version` use the VM's default:
  `mise use -g ruby@4.0` on the VM, then redeploy with
  `~/app.git/hooks/pre-receive HEAD`.

On the VM:

- **mise:** `mise self-update`.
- **Ubuntu packages:** `sudo apt-get update && sudo apt-get upgrade`.
- **Old Rubies and gems:** `mise ls ruby` and `mise uninstall ruby@<version>`.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Troubleshooting</summary>

Start with the app's log. On the VM:

```bash
journalctl -u app -n 100 --no-pager
systemctl status app
```

- **`ActionController::InvalidAuthenticityToken` ("HTTP Origin header
  didn't match request.base_url") on every form, `http://` links, or a
  redirect loop:** Rails thinks requests arrive over plain HTTP. TLS ends
  at exe.dev's proxy, which says so with `X-Forwarded-Proto: https`;
  Thruster passes it on and Rails honors it, but anything in between that
  drops or rewrites the header (your own nginx, say) breaks that. Set
  `config.assume_ssl = true` in `config/environments/production.rb`, so
  Rails treats every request as HTTPS, and `config.force_ssl = true` for
  Secure cookies and HSTS. Rails 8.1 generates both commented out.
- **`Blocked hosts: my-rails.exe.xyz`:** your app sets `config.hosts`.
  Add `"my-rails.exe.xyz"` and your own domains to it, or `".exe.xyz"`.
- **`Deploy FAILED` during `bundle install`:** with `Your bundle only
  supports platforms ...`, run `bundle lock --add-platform x86_64-linux`
  in your checkout, commit and push. With a compile error, install the
  missing `-dev` package with apt and push again.
- **`Deploy FAILED` in `assets:precompile`:** an initializer probably reads
  credentials or an environment variable at boot. Make it tolerate
  `SECRET_KEY_BASE_DUMMY` (as Rails' own Dockerfile build does), or add the
  variable to `~/app.env`.
- **`ActiveSupport::MessageEncryptor::InvalidMessage`, or credentials are
  empty:** `RAILS_MASTER_KEY` in `~/app.env` is missing or wrong. For
  per-environment credentials, use the key from
  `config/credentials/production.key`.
- **The app broke after a deploy:** roll back with
  `git push -f exe <good-sha>:main`. Migrations aren't undone.
- **`database is locked`:** something holds a long write transaction, often
  a script run with `sqlite3` while the app writes. Keep long work in
  Solid Queue jobs, which commit as they go.
- **Down after a reboot:** `systemctl is-enabled app` should say
  `enabled`; if not, `sudo systemctl enable --now app`.

</details>

## Frequently asked questions

### Can I deploy with Kamal instead?

Yes, though this recipe's `git push` needs no Docker at all. Kamal builds an image and runs it over SSH, and the VM has Docker. In `config/deploy.yml`, set `servers` to `my-rails.exe.xyz`, `ssh: {user: exedev}` (it's in the docker group; Kamal's default is root), and `proxy: {ssl: false, host: my-rails.exe.xyz, run: {http_port: 8000}}`, so kamal-proxy listens on port 8000 and leaves TLS to exe.dev. Keep `config.assume_ssl`, and run `sudo systemctl enable docker` on the VM so the containers come back after a reboot. Rails' default `registry: localhost:5555` sends the image back over an SSH port forward; if `kamal setup` fails with "Failed to establish port forward", use a hosted registry such as ghcr.io. To run Rails' Dockerfile without Kamal, see [Docker Compose](/deploy/docker-compose).

### Is SQLite really fine in production?

For one server, yes: it's Rails 8's default, and the VM's disk is persistent. Rails runs SQLite in WAL mode, so readers don't block the writer, and Solid Queue, Solid Cache and Solid Cable use their own database files. Move to PostgreSQL when you need several app servers or heavy concurrent writes.

### Can I use PostgreSQL?

Yes. [Run PostgreSQL next to your app](/deploy/postgres#run-it-next-to-your-app): its `create-db.sh` writes a `DATABASE_URL` that you append to `~/app.env`, and Rails reads `DATABASE_URL` before `config/database.yml`. The `pg` gem needs `sudo apt-get install -y libpq-dev`. That recipe brings its own nightly dumps; the deploy hook's backup covers only SQLite.

### Does a deploy cause downtime?

A few seconds while Puma restarts; the build and migrations run while the old version keeps serving. Thruster answers 502 in that window.

### How much does it cost?

The VM; see [exe.dev plans](https://exe.dev/pricing). There are no add-ons: the database, job queue, cache and HTTPS are included, and the VM doesn't sleep.

### Can I run a Rails console?

Yes: `ssh my-rails.exe.xyz`, then load the app's environment and run it, as in [Settings and secrets](#settings-and-secrets).

### My Rails app is on Heroku, with Postgres and Redis. Which page?

[Apps from Heroku](/deploy/migrate-heroku): it moves the config vars, the Postgres data and the Procfile processes while Heroku keeps serving. This page fits apps on SQLite, or new ones.


## Related

- [Deploy a Laravel app](https://exe-staging.dev/deploy/laravel.md): Your Laravel app on a server of its own. git push to deploy.
- [Self-host PostgreSQL](https://exe-staging.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Migrate a Heroku app to exe.dev](https://exe-staging.dev/deploy/migrate-heroku.md): Your Procfile, config vars, and Postgres on a VM of your own.
- [Deploy a Django app](https://exe-staging.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Deploy a Docker Compose app from GitHub](https://exe-staging.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
- [Deploy a FastAPI app](https://exe-staging.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
