Skip to content

Deploy a Rails app

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

Deploy on exe.dev

Shelley handles the setup. About 10 minutes.

Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/rails/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

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.

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.
What's running, and where
Private https://my-rails.exe.xyz/ Your app, until you make it public
Private ssh my-rails.exe.xyz git push exe main, a shell, logs and the Rails console
my-rails.exe.xyz exe.dev VM
Thruster and Puma Rails 8's production server, run by systemd as app.service :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
Solid Queue Background and recurring jobs, inside Puma
git push deploys A bare repository whose hook builds, migrates and restarts ~/app.git
Nightly backups Consistent SQLite copies, uploads and app.env; two weeks kept ~/backups

Deploy with Shelley#

Click Deploy and Shelley will set up Ruby on Rails on a new VM, then check that it works. No config-file wrangling required.

Have these ready; Shelley asks for them:

  • REPO: the GitHub repository to deploy, as owner/name or a URL, or new to start a fresh Rails app
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/rails/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin

When it's done, run this on your computer (only you can):

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
What Shelley is told
REPO=<fill-this-in>

Set up Ruby on Rails on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/rails.md from top to bottom.

The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh Rails app
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-rails. This VM's name is the output of `hostname`: use it wherever the recipe writes my-rails. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- 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.

Read the whole recipe, including the closed details, and follow "Install
by hand" in order, skipping VM creation. The walkthrough above it is for
the owner afterwards. Ask for REPO first if it says <fill-this-in>.
For REPO=new, follow "Start a new app" exactly. For a repository, follow
"Bring your app": adapt ~/app.env, apt packages and app.service to the
app as its checklist says, but never commit to the owner's code, in
GitHub or in ~/app.git, so their first push fast-forwards. If a private
repository can't be cloned, give the owner the `integrations add github`
command with this VM's name and wait for them.
Keep the deploy hook and backup script as written, since the page
documents how they behave; if one looks wrong, say so at the end.
Don't reboot, don't make the VM public, and don't run the Restore steps;
test the backup in a scratch directory as written.
Finish with the app's URL; only the one git command from `after` that
fits (`git clone -o exe` for REPO=new, `git remote add exe` for a
repository); what you changed for their app; and, if it has a
config/credentials.yml.enc, the command from "Settings and secrets" that
sends its master key.

Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
  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

Also at /deploy/rails/prompt.txt. The recipe it follows is /deploy/rails.md.

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 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:

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:

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.

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):

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:

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):

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:

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:

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.

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:

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 the app with, and tells your app who they are: signed-in requests carry an X-ExeDev-Email header (Login with exe). For an internal tool, that can be all the authentication you need. Add a concern:

# 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:

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. 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.

Install by hand

Create the VM#

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

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 recommends mise 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:

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.

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:

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:

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 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.

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.

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:

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:

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, with no token on the VM:

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:

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, 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) 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).
  • 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:

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:

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#

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:

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.

Back up and restore

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 up to your first deploy, then copy the archive over from your computer:

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:

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:

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.

Update Rails, Ruby and the VM

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), 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>.
Troubleshooting

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

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.

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.

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: 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. 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.

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

Apps from 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.

Updated Oct 17, 2026 · tested end to end Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us