```
### Customizing controller behavior
If you copy a RailsFast partial and want to customize the JavaScript behavior:
1. **Copy the controller** from `controllers/railsfast/...` to `controllers/app/...`
2. **Rename the identifier** in your copied view from `railsfast--...` to `app--...`
3. **Edit your controller** however you need
For example, to customize the FAQ behavior:
```bash
# Copy the controller
cp app/javascript/controllers/railsfast/marketing/faq_controller.js \
app/javascript/controllers/app/marketing/faq_controller.js
```
Then update your copied partial to use `app--marketing--faq` instead of `railsfast--marketing--faq`.
>[!TIP] If you only need to restyle a component (no JS changes), keep the `railsfast--...` identifier. Only switch to `app--...` when you need different behavior.
---
## AI image generation
---
title: AI image generation
sort: 2
---
---
## LLMs and AI chats
---
title: LLMs and AI chats
sort: 3
---
---
## AI, vector databases, and RAG
RailsFast includes built-in support for AI-powered features, vector search, and Retrieval-Augmented Generation (RAG) to help you build intelligent applications.
## Overview
Modern Rails applications can leverage AI for:
- Semantic search and similarity matching
- Content recommendations
- Document question-answering
- Intelligent data retrieval
- Contextual assistance
## Vector Databases
Vector databases store embeddings (numerical representations of text) that enable semantic search.
### Supported Solutions
RailsFast works with:
- **pgvector** - PostgreSQL extension (recommended for most projects)
- **Pinecone** - Managed vector database
- **Weaviate** - Open-source vector search engine
- **Milvus** - Scalable vector database
### Using pgvector
pgvector is included in your PostgreSQL setup.
#### Enable the Extension
```ruby
# db/migrate/xxx_enable_pgvector.rb
class EnablePgvector < ActiveRecord::Migration[8.0]
def change
enable_extension 'vector'
end
end
```
#### Create a Model with Embeddings
```ruby
# db/migrate/xxx_add_embeddings_to_documents.rb
class AddEmbeddingsToDocuments < ActiveRecord::Migration[8.0]
def change
add_column :documents, :embedding, :vector, limit: 1536
add_index :documents, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
end
end
```
```ruby
# app/models/document.rb
class Document < ApplicationRecord
has_neighbors :embedding
end
```
## Generating Embeddings
### Using OpenAI
Install the OpenAI gem:
```ruby
# Gemfile
gem 'ruby-openai'
```
Generate embeddings:
```ruby
# app/services/embedding_service.rb
class EmbeddingService
def self.generate(text)
client = OpenAI::Client.new(
access_token: Rails.application.credentials.dig(:openai, :api_key)
)
response = client.embeddings(
parameters: {
model: "text-embedding-3-small",
input: text
}
)
response.dig("data", 0, "embedding")
end
end
```
Use it in your model:
```ruby
class Document < ApplicationRecord
has_neighbors :embedding
after_save :generate_embedding, if: -> { content_changed? }
private
def generate_embedding
update_column :embedding, EmbeddingService.generate(content)
end
end
```
## Semantic Search
### Finding Similar Documents
```ruby
# Find documents similar to a query
query = "How do I set up payments?"
query_embedding = EmbeddingService.generate(query)
similar_docs = Document.nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(5)
```
### Building a Search Controller
```ruby
# app/controllers/search_controller.rb
class SearchController < ApplicationController
def show
@query = params[:q]
return if @query.blank?
query_embedding = EmbeddingService.generate(@query)
@results = Document.nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(10)
end
end
```
## RAG (Retrieval-Augmented Generation)
RAG combines vector search with LLMs to provide contextual answers.
### Basic RAG Implementation
```ruby
# app/services/rag_service.rb
class RagService
def self.answer_question(question)
# 1. Find relevant documents
query_embedding = EmbeddingService.generate(question)
relevant_docs = Document.nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(3)
# 2. Build context from documents
context = relevant_docs.map(&:content).join("\n\n")
# 3. Generate answer with LLM
client = OpenAI::Client.new(
access_token: Rails.application.credentials.dig(:openai, :api_key)
)
response = client.chat(
parameters: {
model: "gpt-4-turbo-preview",
messages: [
{
role: "system",
content: "Answer questions based on the provided context."
},
{
role: "user",
content: "Context:\n#{context}\n\nQuestion: #{question}"
}
]
}
)
response.dig("choices", 0, "message", "content")
end
end
```
### RAG Controller
```ruby
# app/controllers/ai_assistant_controller.rb
class AiAssistantController < ApplicationController
def ask
@question = params[:question]
@answer = RagService.answer_question(@question)
respond_to do |format|
format.json { render json: { answer: @answer } }
format.html
end
end
end
```
## Advanced Patterns
### Hybrid Search
Combine traditional search with vector search:
```ruby
class Document < ApplicationRecord
has_neighbors :embedding
def self.hybrid_search(query, limit: 10)
# Vector search
query_embedding = EmbeddingService.generate(query)
vector_results = nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(limit * 2)
# Keyword search
keyword_results = where("content ILIKE ?", "%#{query}%")
.limit(limit * 2)
# Combine and deduplicate
(vector_results + keyword_results).uniq.take(limit)
end
end
```
### Caching Embeddings
Cache embeddings to reduce API costs:
```ruby
class EmbeddingService
def self.generate(text)
cache_key = "embedding:#{Digest::SHA256.hexdigest(text)}"
Rails.cache.fetch(cache_key, expires_in: 30.days) do
# API call to generate embedding
client = OpenAI::Client.new(...)
# ... generate and return embedding
end
end
end
```
### Batch Processing
Generate embeddings in background jobs:
```ruby
class GenerateEmbeddingsJob < ApplicationJob
queue_as :default
def perform(document_ids)
documents = Document.where(id: document_ids, embedding: nil)
documents.each do |doc|
doc.update(embedding: EmbeddingService.generate(doc.content))
end
end
end
# Usage
GenerateEmbeddingsJob.perform_later(Document.pluck(:id))
```
## Configuration
Add your AI service credentials:
```bash
EDITOR="cursor --wait" bin/rails credentials:edit
```
```yaml
openai:
api_key: sk-...
# Or for other services
anthropic:
api_key: sk-ant-...
pinecone:
api_key: ...
environment: us-east-1-aws
```
## Performance Tips
1. **Index your embeddings**: Use appropriate index types (IVFFlat, HNSW)
2. **Batch embed operations**: Generate embeddings in batches for better throughput
3. **Cache frequently used embeddings**: Reduce API costs
4. **Use smaller models when possible**: `text-embedding-3-small` is faster and cheaper
5. **Implement retry logic**: Handle API failures gracefully
## Cost Optimization
- Cache embeddings aggressively
- Use background jobs for non-urgent embedding generation
- Choose appropriate embedding models (smaller = cheaper)
- Implement rate limiting on user queries
- Monitor usage and set budgets
## Example Use Cases
### Documentation Search
```ruby
# Find relevant docs for a user question
question = "How do I configure email?"
answer = RagService.answer_question(question)
```
### Content Recommendations
```ruby
# Find similar articles
article = Article.find(params[:id])
similar = Article.nearest_neighbors(:embedding, article.embedding).limit(5)
```
### Intelligent Support Bot
```ruby
# Answer customer questions
customer_query = "Do you offer refunds?"
support_answer = RagService.answer_question(customer_query)
```
## Resources
- [pgvector GitHub](https://github.com/pgvector/pgvector)
- [Neighbor gem (Rails integration)](https://github.com/ankane/neighbor)
- [OpenAI Embeddings Guide](https://platform.openai.com/docs/guides/embeddings)
- [RAG Explained](https://www.anthropic.com/research/rag)
## See Also
- [[features/4-background-jobs|Background Jobs]] - For async embedding generation
- [[getting-started/4-configuration|Configuration]] - Setting up AI credentials
- [[getting-started/5-develop|Development]] - Testing AI features locally
---
## How to get a 10/10 deliverability score for your emails
RailsFast can achieve a 10/10 email deliverability out of the box, as tested by mail-tester.com
To achieve that, when configuring AWS SES for sending email, make sure to strictly follow these steps:
- Navigate to your Amazon SES dashboard
- Add your project domain name as a SES identity
- To do that, on the sidebar, navigate to Configuration > Identities and click [ Create Identity ]
- In the new page, select "Domain" as the new identity type
- Enter your domain
- Activate the "Use a custom MAIL FROM domain" checkbox
- In the MAIL FROM field, input something like `mail.
`
- Scroll down and select "Easy DKIM"
- Select RSA_2048_BIT
- Leave "DKIM Signatures" active
- Click on the [ Crete identity ] button
- On the new identiy page, you'll see three main sections: DKIM, MAIL FROM, and DMARC. For each section, there's a "Publish DNS records" unfoldable item. If you click it, you'll see the DNS records you need to add to your domain's DNS to clear each section. It's a somewhat tedious job, but make sure you add all records right.
- Once you're done adding all records, scroll up in the indentity page in SES and click the refresh button. If everything went right, you should see your "Identity status: Verified", and the DKIM and MAIL FROM sections marked as successful. If that did not happen, give your DNS time to propagate (anywhere from some minutes to a few hours) and try again.
- RailsFast is configured with best email practices so that when you send a transactional email from Rails you should get a perfect 10/10 score (or pretty close to it) in deliverability: you can test it at mail-tester.com
> [!TIP]
> **Terminal / agent lane**: check verification status without the dashboard — `aws sesv2 get-email-identity --email-identity yourdomain.com --query '{Verified:VerifiedForSendingStatus, Dkim:DkimAttributes.Status, MailFrom:MailFromAttributes.MailFromDomainStatus}'` (all three should say verified/SUCCESS — with Cloudflare DNS this typically takes ~2 minutes, not hours). Then prove the whole chain with a real email from production:
> ```bash
> bin/kamal app exec --reuse 'bin/rails runner "Goodmail.compose(to: %q(you@example.com), from: %q(hello@yourdomain.com), subject: %q(SES test)) { h1 %q(It works!); text %q(Sent from production via SES.); sign }.deliver_now"'
> ```
> The received message should show SPF, DKIM **and** DMARC all `PASS` in its headers.
## After launch: the email lifecycle
Deliverability isn't a setup step, it's a lifecycle. Three things keep your 10/10 honest over time:
1. **Turn on Cloudflare's DMARC Management for the zone** (Email → DMARC Management in the dashboard — free). It rewrites your DMARC record with a `rua` reporting address and dashboards who is sending as your domain. Without reports, you're graduating policy blind.
2. **Graduate your DMARC policy.** The quickstart starts you at `p=none` (monitor only) — that's correct for day one and wrong forever. After 2–4 weeks of clean reports (all mail SPF/DKIM-aligned, no legitimate source failing), move to `p=quarantine`, and later `p=reject`. It's a one-line DNS TXT edit — fully agent-executable via the same Cloudflare API lane as the quickstart.
3. **Wire bounce and complaint handling in SES.** Reputation damage is silent by default: create an SNS topic, subscribe an email (or endpoint) you actually read, and attach it to your identity's Bounce and Complaint notifications (SES → Configuration → your identity → Notifications, or a configuration set with an event destination). A rising complaint rate you don't see is how sending privileges die. SES's account-level suppression list is on by default — leave it on.
---
## Kamal - Configure a container registry
Kamal uses your local development machine as a Docker container registry by default.
This means that, after the Docker image containing your app is built, it's stored in your local computer and needs to be uploaded from your computer to the production server for deployment.
If your internet connection is not the best, this may slow down your total deployment time from the time you hit `kamal deploy` until the time everything is live in production. Also, you may be filling up your local machine with big stale Docker images.
> [!TIP]
> Using a remote container registry usually speeds up deployment times.
There are many remote container registry options: Docker Hub, ghcr.io, GitLab container registry, etc.
Some are paid, some are free (but always public, which is not supported on RailsFast).
What I found to be easiest to configure and integrate with Kamal (RailsFast) is AWS ECR.
## How to use AWS ECR with Kamal as container registry
AWS ECR costs close to nothing (I paid $0.32 –yes, 32 cents– last month), and it's very straightforward to integrate with Kamal using the AWS CLI. And since we're already using AWS for storage (S3) and emailing (SES) in RailsFast, it makes sense to also leverage their container registry.
### Install and configure the AWS CLI
First, [install and configure the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html).
Then, at the end of the Kamal secrets file under `.kamal/secrets`, uncomment this line:
```
AWS_ECR_PASSWORD=$(aws ecr get-login-password --region us-east-1 --profile default)
```
Make sure to change the `us-east-1` for the region you're using in ECR, or leave it like that if you're fine with us-east-1.
> [!INFO]
> This is a secure way of sharing your AWS ECR credentials with Kamal for deployment. No password, API token or anything sensible ever gets commited or made public. Upon every deployment, Kamal will call the AWS CLI on your local computer to get a temporary login password for ECR, and will load that token as the `AWS_ECR_PASSWORD` ENV var in the containers that need it.
### Create a private registry on AWS ECR
Go to your AWS console and navigate to [ECR (Elastic Container Registry)](https://us-east-1.console.aws.amazon.com/ecr/private-registry/repositories?region=us-east-1).
Click on the ` [ Create repository ] ` button.
Give your registry a name with a username in front. Example: use `myusername/myproject`, not just `myproject`. This is because Docker container registries require username namespacing (a container must belong to a username). Tip: just use the same username you use on GitHub and the same project name you use on GitHub.
Keep the rest of the settings default and click on `[ Create ]`
Since we're at it, let's do a couple more clicks to limit how many versions of our Docker images the registry is going to store (saving us storage money!)
Select your recently created repository from the list of private repositories. On top, click `[Actions ▼]` and then `Lifecycle policies`. Click on `[Create rule]` to create a new rule, and in the new screen leave everything by default but where it says: `Image tag status` (select: `Any`). Then, under `Match criteria`, I suggest you select `Days since image created` or `Image count`. In the box below, you can specify for how many days you want to store each version, or how many version (images) in total you want to storage.
I personally just select `Image count`, and set it to only save the last `30` images. Click `[Save]` and `[Confirm]` and you won't be storing old, stale versions forever.
> [!TIP]
> **Terminal / agent lane**: repository + lifecycle policy in two AWS CLI calls:
> ```bash
> aws ecr create-repository --repository-name myusername/myproject --region us-east-1 \
> --query 'repository.repositoryUri' --output text
> aws ecr put-lifecycle-policy --repository-name myusername/myproject --region us-east-1 \
> --lifecycle-policy-text '{"rules":[{"rulePriority":1,"description":"Keep last 30 images","selection":{"tagStatus":"any","countType":"imageCountMoreThan","countNumber":30},"action":{"type":"expire"}}]}'
> ```
> The first command prints the repository URI you'll paste into `deploy.yml`.
### Edit `deploy.yml`
Now that we have everything configured in ECR, in your `config/deploy.yml` let Kamal know which container registry you're using:
- Change your `image` name to match the name you gave it in AWS ECR. Example, if before you had something like:
```yaml
image: myproject
```
Edit it to make it match what you had on ECR:
```yaml
image: myusername/myproject
```
- Remove the `localhost:5555` `server` under the `registry` section and uncomment the placeholder remote container registry options (`server`, `username`, `password`), to end up with something like this:
```yaml
registry:
server: 1234.dkr.ecr.us-east-1.amazonaws.com
username: AWS
password:
- AWS_ECR_PASSWORD
```
Replace the placeholder `1234.dkr.ecr.us-east-1.amazonaws.com` with the actual repository URI you can see on ECR in your private repositories list.
> [!IMPORTANT]
> If you're using a remote builder, make sure to pass the `AWS_ECR_PASSWORD` ENV var to the builder too, under the `secrets` section of the `builder` key:
>
> ```
> builder:
> secrets:
> - RAILS_MASTER_KEY
> - AWS_ECR_PASSWORD
>```
And you're done!
You can check the remote Docker container registry in AWS ECR is set up correctly in Kamal by running:
```bash
kamal registry setup
```
And seeing that it logs in correctly.
The next time you deploy using `kamal deploy`, Kamal will push and pull the Docker images from ECR instead of from your local computer registry.
> [!NOTE]
> **ECR logins expire** (the tokens last 12 hours), so a deploy the next day can fail with `403 Forbidden` / _"unexpected status from HEAD request"_ even though your credentials are fine. The fix is always the same — refresh the login and ship: `bin/kamal registry login && bin/kamal deploy`. If you deploy to ECR daily, just make that pair your default deploy command.
---
You can learn more about Kamal and container registries in the [official Kamal docs](https://kamal-deploy.org/docs/configuration/docker-registry/)
---
## Kamal - Configure a remote builder
> [!IMPORTANT]
> If you're on macOS, especially M1, M2, M3... (Apple Silicon), you may **have to** use a remote builder or your build process may fail.
A remote builder is just a server that builds your app for you, instead of doing the build work in your local machine. You send your code to the remote builder, and it produces the deployeable Docker image.
This is useful, for example, in case your local development machine is too slow building, and you want to speed things up.
It may also me something you **need to** to if you're on macOS.
The newest macOS machines (the Apple Silicon family: M1, M2, M3, etc.) may find issues with Docker (the underlying technology Kamal uses for building and deploying your app); **this is fully solvable** if you use a remote builder instead of your local Mac for building.
## How to set up a remote builder server in Kamal
Setting up a remote builder couldn't be easier: just spin up a new server on Hetzner exactly as you did on the [[getting-started/2-quickstart|Quickstart]]. In a nutshell: select a server and a location, add your SSH key, __no need__ for backups this time, and in the **Cloud Config** box, paste this:
```yaml
#cloud-config
runcmd:
- bash -lc 'set -euo pipefail; wget -qO /root/railsfast-setup.sh https://setup.railsfast.com; chmod +x /root/railsfast-setup.sh; /root/railsfast-setup.sh 2>&1 | tee -a /var/log/railsfast-setup.log && reboot'
```
Create the server and wait for the setup script to finish as described in the [[getting-started/2-quickstart|Quickstart]]. (It's the same [[guides/server-setup-script|RailsFast server setup script]] used for deploy targets — it also configures builder hosts, including bounded Docker build-cache storage.)
Then, in your `deploy.yml` file, uncomment the remote builder machine section under the `builder` key:
```yaml
builder:
arch: amd64
# Uncomment this:
local: false
remote: ssh://docker@5.6.7.8
args:
RUBY_VERSION: 4.0.5 # keep in sync with .ruby-version
secrets:
- RAILS_MASTER_KEY
```
And change the placeholder `5.6.7.8` IP for the actual IP of the server you've just created.
That's it! Try deploying with `kamal deploy` now and your setup will use the remote builder for building, instead of your local machine.
---
## Migrating Existing Project
Here's the simplest, most reliable way to "adopt" RailsFast into an existing Rails project and start getting updates.
### Goal
- Link your project’s history to the RailsFast template so future updates are just normal git merges.
- Keep your current code as-is during adoption.
- Preserve secrets/config with merge guards.
### One-time adoption (manual)
1) Prep
- Commit or stash everything: `git status` should be clean.
- Create a safety branch: `git checkout -b adopt-railsfast-backup`
2) Add RailsFast as a remote
```bash
git remote add railsfast git@github.com:railsfast/railsfast-base.git
git fetch railsfast --prune
```
3) Establish a baseline relationship (don’t change your files)
- This records the template as a merge parent without altering your tree.
```bash
git merge --allow-unrelated-histories -s ours --no-ff -m "Adopt RailsFast upstream at baseline" railsfast/main
```
- Result: histories are now related. Future `git merge railsfast/main` will bring only changes made in the template since this baseline.
4) Add merge guards so protected files always keep your local version
- If you already have `.gitattributes`, add lines like:
```bash
echo '# RailsFast: Preserve local configuration during template merges' >> .gitattributes
echo 'Dockerfile merge=ours' >> .gitattributes
echo '.kamal/secrets merge=ours' >> .gitattributes
echo 'app/views/pwa/manifest.json.erb merge=ours' >> .gitattributes
echo 'config/application.rb merge=ours' >> .gitattributes
echo 'config/database.yml merge=ours' >> .gitattributes
echo 'config/deploy.yml merge=ours' >> .gitattributes
echo 'config/railsfast.yml merge=ours' >> .gitattributes
echo 'config/credentials.yml.enc merge=ours' >> .gitattributes
echo 'config/master.key merge=ours' >> .gitattributes
echo 'config/db.key merge=ours' >> .gitattributes
```
- Configure the merge driver + rerere:
```bash
git config merge.ours.driver true
git config rerere.enabled true
git add .gitattributes
git commit -m "Configure RailsFast merge guards"
```
5) (Optional) Record metadata for RailsFast CLI status
- If your repo already includes `bin/railsfast`, you can record the baseline and remote without changing your app:
```bash
bin/railsfast init --yes --skip-rename --skip-setup
```
- This writes `.railsfast/config.json` and remembers the remote/branch/baseline. If you don’t have the scripts yet, you can skip this step.
### Verifications
- Check relation and baseline:
```bash
git log --oneline --graph --decorate --all | head -n 50
```
- See behind count:
```bash
git rev-list --count HEAD..railsfast/main
```
---
## Deploy multiple Rails apps to the same server
Running multiple apps in the same VPS server is trivial with RailsFast.
Just configure `deploy.yml` to deploy to the same server IP, that's it! Nothing else to do on your side.
All apps in the same server will have separate databases and run in separate containers so there's no risk of cross-pollination whatsoever. Your RailsFast apps are like silos, they just happen to be in the same VPS!
---
## Restoring your database backups — the runbook
A backup you've never restored is hope, not disaster recovery. This page is the **verified restore runbook** for RailsFast's default backup setup ([[getting-started/4-configuration|configured here]]) — every command below was proven in a live restore drill: seed real data, take a backup, destroy the data, restore it, and prove it back **through the app** (Devise passwords authenticating, new writes succeeding), not just with row counts.
> [!CAUTION]
> **Do not use the backup image's bundled `/backup-scripts/restore.sh`.** In our drill, all three of its backup-selection modes failed on a stock RailsFast app — including one that **silently resolves the wrong database** (ask for `myapp_production`, get `myapp_production_queue`, and it will happily replace your users with Solid Queue's tables), and a version self-check that refuses the image's own backups. The manual procedure below is the reliable path, and it needs nothing beyond what the `pg-backup` container already has.
## What you're restoring
RailsFast runs four databases: `myapp_production` plus `_queue`, `_cache`, and `_cable`. **Only `myapp_production` holds your business data.** The other three belong to Solid Queue/Cache/Cable and should be **rebuilt, not restored** — restoring the queue resurrects stale jobs. After restoring the primary, just run `bin/kamal app exec --reuse 'bin/rails db:prepare'` and the ephemeral three recreate themselves in seconds.
Backups land in S3 as `s3://////PG_..dmp.gz`, one file per database, timestamped one minute apart, plus one `globals.sql` at the app root (roles — only needed if you lost the whole postgres accessory).
## Step 1 — Find the dump
```bash
aws s3 ls s3://YOUR-BUCKET/YOUR-APP/ --recursive --human-readable
```
Pick the file whose name contains the database you want — the one named exactly `..._production` is your business data.
## Step 2 — Restore into a scratch database first
Never point your first restore at production. Save this locally as `restore.sh` — it runs *inside* the `pg-backup` container, which already has the S3 credentials, network access, and client tools:
```bash
set -uo pipefail
KEY="${1:?s3 key relative to $BUCKET}"; DB="${2:?target database}"
WORK=/data/manual-restore; mkdir -p "$WORK"; rm -f "$WORK/dump" "$WORK/dump.gz"
cat > /root/.s3cfg < "$WORK/err.log"
echo "pg_restore exit=$?"
sort -u "$WORK/err.log" | head
psql ${PG} -d "${DB}" -At -c "SELECT 'users='||count(*) FROM users"
```
Run it against a throwaway database name (note: `kamal accessory exec` can't pass stdin/env like this, so go over plain `ssh` + `docker exec`):
```bash
ssh -i ~/.ssh/YOUR_KEY docker@YOUR_SERVER_IP \
'docker exec -i YOURAPP-pg-backup bash -s -- "2026/July/PG_yourapp_production.28-July-2026-02-00.dmp.gz" "yourapp_restore_check"' \
< restore.sh
```
> [!IMPORTANT]
> **Expect `pg_restore exit=1` — on this stack, that's success.** The image's newer client tools emit a couple of `unrecognized configuration parameter "transaction_timeout"` warnings against the postgres 15 accessory and exit non-zero even when the restore is complete and correct. Judge by the error log (only those lines) and the row count — not the exit code. A `set -e` wrapper here will abort perfectly good restores.
Confirm the row counts look right before going anywhere near production.
## Step 3 — Cut over to production
Same command, real database name:
```bash
ssh -i ~/.ssh/YOUR_KEY docker@YOUR_SERVER_IP \
'docker exec -i YOURAPP-pg-backup bash -s -- "2026/July/PG_yourapp_production.28-July-2026-02-00.dmp.gz" "yourapp_production"' \
< restore.sh
```
You don't have to stop the app: `dropdb --force` terminates its connections and Rails reconnects on its own (verified — the site served 200s immediately after). Requests arriving mid-restore will error, so stop the app first if you want a clean window, but it's not required for correctness.
## Step 4 — Prove it through the app
Row counts prove presence; the app proves usability:
```bash
bin/kamal app exec --reuse 'bin/rails runner "puts User.count"'
# and for real confidence, check something cryptographic survived:
bin/kamal app exec --reuse 'bin/rails runner "puts User.first&.email.inspect"'
```
## Step 5 — Rebuild the ephemeral databases
```bash
bin/kamal app exec --reuse 'bin/rails db:prepare'
```
Done. Fresh queue/cache/cable, restored business data, live app.
## Practice this before you need it
The whole drill — scratch restore and all — takes about fifteen minutes against a live app, and it's the only way to know your backups are real. Do it once after configuring backups (this replaces "list the bucket" as the meaningful verification), and again after anything that changes the backup pipeline. If you lost the entire postgres accessory (volume and all), apply `globals.sql` first to recreate the role, then restore per database.
---
## The server setup script — hardening & maintenance
Every RailsFast server — deploy targets and [[guides/kamal-remote-builder|remote builders]] alike — is initialized by one script: the **RailsFast production server setup script**, served at [setup.railsfast.com](https://setup.railsfast.com) (free & open source, MIT — it lives [in this gist](https://gist.github.com/rameerez/238927b78f9108a71a77aed34208de11)).
It takes a fresh **Ubuntu 24.04 LTS or 26.04 LTS** VPS and turns it into a hardened, production-ready host for Kamal 2.x Docker deployments. This page documents what it does, the knobs it has, and how to maintain a server afterwards.
## Running it
You normally never run it by hand — the [[getting-started/2-quickstart|Quickstart]] passes it to the server via cloud-init on creation. To run it manually on a fresh server:
```bash
ssh root@your-server-ip
wget -qO railsfast-setup.sh https://setup.railsfast.com
less railsfast-setup.sh # always read a script before you run it
bash railsfast-setup.sh
```
> [!TIP]
> **The script is idempotent: re-running it is always safe.** It only changes what needs changing (it won't even restart Docker if the config is already right). Re-running is also how you upgrade a server that was set up with an older version of the script — v3 removes the old version's footguns for you (see the changelog at the top of the script).
### Optional knobs
Set these as environment variables before running (e.g. `INSTALL_MOSH=true bash railsfast-setup.sh`):
| Variable | Default | What it does |
|---|---|---|
| `DOCKER_USERNAME` | `docker` | The deploy user Kamal connects as (`ssh.user` in `deploy.yml`) |
| `SSH_PUBLIC_KEY` | *(empty)* | Explicitly provision the deploy user's SSH key (otherwise it's copied from root's / the sudo user's `authorized_keys`) |
| `INSTALL_MOSH` | `false` | Installs [mosh](https://mosh.org) and opens UDP 60000:61000 |
| `INSTALL_AUDITD` | `false` | Installs auditd with Docker-focused audit rules |
| `SETUP_SWAP` | `auto` | `auto` adds a swap file to every server that has none (2G, or 4G on ≥4GB-RAM servers); `true`/`false` force it |
| `SKIP_OS_CHECK` | `false` | Run on untested Ubuntu versions, at your own risk |
| `ALLOW_INCOMPLETE_SSH` | `false` | Let the script finish on a server with no usable SSH key (normally that fails the run — Kamal couldn't connect anyway). Even then, the canonical success line is withheld |
> [!NOTE]
> The script verifies everything it did at the end, and **fails loudly**: the `👽 SUCCESS: Setup complete!` line only prints when every check passed — including that the deploy user has a usable SSH key. On failure it exits non-zero — under the cloud-init flow the server then does *not* reboot, and `/var/log/cloud-init-output.log` (or `/var/log/railsfast-setup.log`) shows exactly which check failed. The script is always safe to re-run after fixing the cause.
## What it sets up
- **System updates + automatic security updates.** Ubuntu's `unattended-upgrades` stays on its distro defaults (security patches install daily); the script only adds a couple of preferences in a drop-in. Kernel updates still need a reboot — see [maintenance](#maintaining-your-server) below.
- **Docker Engine from Docker's official apt repository** (not the `get.docker.com` convenience script, which Docker [doesn't recommend for production](https://docs.docker.com/engine/install/ubuntu/)), with a production daemon config: bounded log files (100MB × 3 per container, so `kamal logs` has history without eating the disk), and `live-restore` so containers keep running when the Docker daemon restarts during upgrades.
- **A key-only deploy user** (`docker` by default) in the `docker` group, seeded with the same SSH key you gave your provider. Heads up: membership in the docker group is [root-equivalent](https://docs.docker.com/engine/security/#docker-daemon-attack-surface) — treat this user's key exactly like a root key.
- **SSH hardening** via a drop-in at `/etc/ssh/sshd_config.d/00-railsfast.conf`: key-only logins, no passwords, no keyboard-interactive, root restricted to key-only as break-glass, verbose auth logging, and the effective configuration is verified with `sshd -T` after applying. The script **never disables password login until an SSH key is actually installed**, so a password-provisioned server can't lock you out — it tells you how to finish hardening instead.
- **Firewall (ufw)**: deny incoming by default, allow only 22, 80, 443.
- **fail2ban** watching SSH via the systemd journal, with incremental bans (repeat offenders get exponentially longer bans, up to a week).
- **Kernel hardening** (`/etc/sysctl.d/99-security.conf`): spoofing/redirect protections for IPv4 *and* IPv6, SYN-flood resilience, restricted kernel introspection (`kptr_restrict`, `dmesg_restrict`, unprivileged eBPF and `kexec` disabled), filesystem link protections.
- **A bounded swap file on every server** (2G, or 4G on ≥4GB-RAM boxes, capped there). Kamal's gapless deploys briefly run the old and new app containers side by side, so the memory spike that invites the OOM killer recurs on *every deploy* — swap absorbs it, and `swappiness=10` keeps it an emergency valve rather than active memory. Servers set up before v3.0.1 gain swap the next time the script is re-run on them.
- **Time sync** (keeps whichever of systemd-timesyncd/chrony the release ships — Ubuntu 26.04 ships chrony) and a **journald disk cap** (500MB) so logs can't fill a small disk.
At the end it verifies everything (including that the deploy user can actually talk to Docker — the one thing Kamal needs) and prints next steps.
## The firewall truth every Docker host should know
Ports **published by Docker containers bypass ufw** — Docker writes its own iptables rules ahead of ufw's ([Docker docs](https://docs.docker.com/engine/network/packet-filtering-firewalls/)). In the default RailsFast topology this is fine: only kamal-proxy publishes ports (80/443, which you want public); the postgres and pg-backup accessories publish **nothing** and are reachable only on the private `kamal` Docker network.
Two rules keep it that way:
1. **Add your provider's firewall as the outer layer** (e.g. Hetzner Cloud Firewall allowing only 22, 80, 443). It filters before packets ever reach the server, so nothing Docker does can bypass it.
2. **If you add custom accessories with a `port:`, bind them to loopback**: `port: "127.0.0.1:9200:9200"`, never `port: "9200:9200"` — as also noted in the [[getting-started/2-quickstart|Quickstart]].
## Things the script deliberately does NOT do
Knowing what's excluded (and why) matters as much as what's included:
- **No nightly `docker system prune` cron.** Kamal already prunes old containers and images automatically after every successful deploy, keeping the last 5 for [`kamal rollback`](https://kamal-deploy.org/docs/commands/rollback/) ([prune docs](https://kamal-deploy.org/docs/commands/prune/)). A blanket prune cron deletes exactly those retained versions and **breaks rollback** — older versions of this script did this; v3 removes the cron when re-run on such servers.
- **No aide / rkhunter / logwatch.** Intrusion-report tools are only useful if their reports reach a human — which requires configuring outbound mail. Unconfigured, they're false confidence, and on Ubuntu they drag in a full mail server (postfix) and even a Ruby interpreter through package dependencies. Install them deliberately if you'll actually read their reports.
- **No automatic reboots.** Unattended 4am reboots are a bigger availability risk than scheduled ones. The trade-off: kernel security updates aren't live until you reboot (see below).
- **No disabling of SSH TCP forwarding.** `AllowTcpForwarding yes` is **load-bearing for Kamal**: the default RailsFast setup (`registry.server: localhost:5555`) pushes images through an SSH reverse tunnel ([Kamal source](https://github.com/basecamp/kamal/blob/main/lib/kamal/cli/build/port_forwarding.rb)). If you "harden" it off, deploys fail with `Failed to establish port forward`. The script pins `GatewayPorts no` instead, which keeps those tunnel listeners loopback-only.
## Verify your server
The setup script ships with a companion **acceptance test** — `verify-server-setup.sh`, published [in the same gist](https://gist.github.com/rameerez/238927b78f9108a71a77aed34208de11). It re-checks every contract the setup makes (~40 read-only assertions: effective SSH config, post-quantum key exchange active, rollback-safe Docker pruning, deploy-user Docker access, firewall rules on the ports sshd actually listens on, fail2ban jail loaded, live kernel hardening values, no package bloat). It changes nothing — it only reads.
Run it as root on a server that completed setup:
```bash
wget -qO verify-server.sh https://verify.railsfast.com
less verify-server.sh # same rule as always: read scripts before running them
bash verify-server.sh
```
Expect `RESULT: PASS=... FAIL=0` and the `ALL GREEN` verdict. Two expected deviations: servers upgraded in place from the old v2 script may show `WARN` lines about leftover packages (the setup script prints the removal recipe), and a server set up with `ALLOW_INCOMPLETE_SSH=true` will fail the SSH checks by design until you finish installing a key.
> [!TIP]
> If you ever report a server issue, run this first and include its output — it pinpoints which layer misbehaves in seconds.
## Maintaining your server
Day-2 operations, in practice:
- **Security patches**: applied automatically every day (userland). **Kernel updates require a reboot** — when `ssh`ing in shows `*** System restart required ***`, reboot at a convenient time (`sudo reboot`; Kamal containers come back automatically). Prefer never rebooting? [Ubuntu Pro](https://ubuntu.com/pro) livepatch is free for up to 5 machines.
- **Docker Engine updates**: third-party repos are *not* auto-upgraded by unattended-upgrades, on purpose. Run `apt update && apt upgrade` during maintenance windows — `live-restore` keeps your containers running through the daemon restart.
- **Check who's knocking**: `fail2ban-client status sshd` shows failed SSH attempts and current bans (on any public IP, there will be plenty — that's the internet, and it means the jail works).
- **Check the effective SSH config**: `sshd -T | grep -E 'passwordauthentication|permitrootlogin'` — this is what sshd *actually* runs with, resolved across all config files.
- **Where things live**: SSH hardening in `/etc/ssh/sshd_config.d/00-railsfast.conf` · Docker daemon config in `/etc/docker/daemon.json` · fail2ban in `/etc/fail2ban/jail.d/railsfast.conf` (your own `/etc/fail2ban/jail.local` overrides it — fail2ban's last-value-wins) · sysctls in `/etc/sysctl.d/99-security.conf` · auto-updates drop-ins in `/etc/apt/apt.conf.d/21auto-upgrades-railsfast` and `52unattended-upgrades-railsfast` · script version marker in `/etc/railsfast-setup.version` (written only after verification passes).
## Troubleshooting
- **Locked out?** Use your provider's web console (e.g. Hetzner's "Console" button) — it's a local terminal, not SSH, so SSH hardening doesn't apply to it.
- **Created the server with a root password instead of an SSH key?** The script detects this, leaves password login enabled (disabling it would lock everyone out), and **fails the run on purpose** — a server Kamal can't connect to isn't ready. Finish with: `ssh-copy-id root@your-server` from your machine, then re-run the script on the server. (Deliberate password-only bootstrap: re-run with `ALLOW_INCOMPLETE_SSH=true`.)
- **`kamal deploy` fails with `Failed to establish port forward`**: something turned off `AllowTcpForwarding` in sshd — re-run the setup script to restore the correct config.
- **A custom accessory is reachable from the internet even though ufw is active**: you published its port without binding to loopback — see [the firewall truth](#the-firewall-truth-every-docker-host-should-know) above.
---
## Stripe for serial founders — one Organization, one account per project
If you run (or plan to run) several projects, don't cram them all into one Stripe account. Stripe **Organizations** gives you an umbrella: one login, unified team management, and up to 75 sibling accounts — one per project — each with its own clean books, its own webhooks, its own keys. Creating a new project account takes about a minute because you can **copy the legal entity, business details, and payout bank account from an existing account in the org** — no re-doing KYC for every side project.
This guide is the exact flow, plus how much of it an AI agent can drive (spoiler: everything except ~5 dashboard clicks and one key handoff).
## Create the org account for a new project (human, ~1 minute)
This part is dashboard-only (there's no API for creating org business accounts):
1. Log in to the [Stripe Dashboard](https://dashboard.stripe.com).
2. Top-left account dropdown → `[ + Create account ]`.
3. Choose **"Create an account in your organization"** (not "Create a separate account").
4. Name the account, pick the **country of operation**, and select the existing account to **copy business data and bank details** from.
5. Create — the dashboard switches to the new account and a short onboarding follows (display name, what the business does, which products to activate).
> [!CAUTION]
> **Check the country before you configure anything.** An account's country is **immutable** after creation — verify it immediately (`stripe get /account` → `country`). If it's wrong (e.g. it defaulted to US but your legal entity is elsewhere), close the account and recreate it now, before wiring anything to it.
At the end of onboarding, Stripe now even offers a **"set up with an AI agent"** path with a copy-paste integration prompt (MCP server, plugin, implementation planner). If you're on RailsFast you can skip the integration-planner part — the [pay](https://github.com/pay-rails/pay) gem *is* your integration, pre-wired. What your agent actually needs is below.
## What the agent does next (everything else)
After the human runs `stripe login` once for the new account (browser step), the CLI holds **all four keys** — test + live, publishable + restricted — in `~/.config/stripe/config.toml` (`chmod 600`). An agent should read them from that file, never echo them.
Verified capability map, per mode:
| Need | How | Agent-executable? |
|---|---|---|
| Test `pk`/`rk` pair (development creds) | `~/.config/stripe/config.toml` after `stripe login` | ✅ fully |
| Development webhook signing secret | `stripe listen --print-secret` | ✅ fully |
| Sandbox webhook endpoint (+ its `whsec_`) | `stripe webhook_endpoints create --url https://yourdomain.com/pay/webhooks/stripe ...` — the response includes the signing secret | ✅ fully |
| Live publishable key | same config file | ✅ fully |
| **Live secret key for the app** | Dashboard: create a **restricted key** → clipboard handoff | 🧑 one human step |
| Live webhook endpoint (+ its `whsec_`) | `STRIPE_API_KEY= stripe webhook_endpoints create --live ...` | ✅ after the handoff |
(That table is the single-account path. Running an [Organization](#the-power-move-one-organization-api-key-verified)? The org key below replaces the per-project restricted-key handoff for everything except the app's own runtime key — and can even replace that, trade-offs noted.)
Two sharp edges the agent must know:
- **The CLI's own keys expire in 90 days** and its live key is deliberately permission-limited (live *writes* like webhook creation get rejected). They're perfect as development credentials and useless as production app credentials — that's by design, not a bug. The app's production key must be a dashboard-created **restricted key** — see the exact permission list below.
- **A live account isn't chargeable until onboarding completes** — gate on `stripe get /account` → `charges_enabled` before believing any live-mode test.
## The exact restricted-key permissions a RailsFast app needs
Derived from the actual Stripe API surface of [pay](https://github.com/pay-rails/pay) v11 (every `Stripe::*` call in the gem), so nothing is over-granted — and **verified live** (webhook creation, customer round trips, and deliberate permission-gap probes against a real org key).
The "Select permissions" screen groups permissions by **resource group**, and each row is `None / Read / Read and write`. Walk it top to bottom — everything not listed below stays on **None**:
**Core** — set `Read and write` for:
- Charges and Refunds
- Customer Sessions *(pay's embedded pricing/payment elements)*
- Customers
- Payment Intents
- Payment Methods
- Products
- Setup Intents
**Billing** — set `Read and write` for:
- Billing Meter Events *(only matters for usage-based billing — harmless to grant)*
- Credit Notes
- Customer Portal
- Invoices
- Prices *(pay and pricing_plans call `Price.retrieve`; write lets an agent create prices for your plans)*
- Subscriptions
- Tax Rates
**Checkout Sessions** — the group has a single permission: set it to `Read and write`.
**Webhook Endpoints** — single permission ("Webhook Endpoints, Event Destinations"): set `Read and write`. This is what lets the **agent** create the production webhook with this same key and capture its `whsec_` signing secret. Drop it back to None afterwards if you like.
Leave every other group (**Connect** included — pay only uses it for marketplace features RailsFast doesn't enable) on **None**. Using Stripe Tax? `automatic_tax` on Checkout works under the permissions above once your tax registrations are configured in the dashboard.
> [!TIP]
> Under-granted? Stripe's error messages name the exact missing permission — e.g. *"Enabling \"Prices Read\" ('plan_read') permissions on this key would allow this request to continue."* An agent hitting `Permission denied` should read the error, report the named permission, and ask the human to add it — no guessing.
The same screen (and the same list) applies to both a **per-account restricted key** and an **organization API key** — see below for which to create.
## Creating your products and prices
Credentials alone don't give you anything to sell. RailsFast plans are defined in code (`config/initializers/pricing_plans.rb`), and each paid plan maps to a Stripe **Product** with recurring **Prices**. Creating those is fully agent-executable under the permission list above:
```bash
# Sandbox first (CLI account keys):
stripe products create --name "Pro"
stripe prices create -d "product=prod_XXX" -d "currency=usd" \
-d "unit_amount=2900" -d "recurring[interval]=month"
stripe prices create -d "product=prod_XXX" -d "currency=usd" \
-d "unit_amount=29000" -d "recurring[interval]=year"
# Live (org key — same calls with the context headers):
curl -s https://api.stripe.com/v1/products \
-u "$(cat ~/.config/railsfast/stripe_org_key):" \
-H "Stripe-Context: acct_XXXXXXXX" -H "Stripe-Version: 2026-06-24.dahlia" \
-d name="Pro"
```
Then wire the returned `price_…` IDs into `config/railsfast/railsfast.yml`:
```yaml
stripe:
plans:
pro:
monthly: price_live_xxx
yearly: price_live_yyy
```
…and in `config/initializers/pricing_plans.rb`, swap each plan's hardcoded `price 29` for the `stripe_price month: …, year: …` line that ships commented out right below it. Sandbox and live have **different** price IDs — `railsfast.yml` supports per-environment values, so wire both.
## The power move: one Organization API key (verified)
If you plan to churn many projects, create a single [organization API key](https://docs.stripe.com/keys/organization-api-keys) instead of a per-account key each time: org dashboard → [org API keys](https://dashboard.stripe.com/org/api-keys/secret) → create a restricted key with exactly the permission list above. It's `sk_org_…`-prefixed, centrally revocable, and works on **any account in the org** — every request just carries two extra headers naming the target:
```bash
curl https://api.stripe.com/v1/webhook_endpoints \
-u "$(cat ~/.config/railsfast/stripe_org_key):" \
-H "Stripe-Context: acct_XXXXXXXX" \
-H "Stripe-Version: 2026-06-24.dahlia" \
-d url="https://myproject.com/pay/webhooks/stripe" \
-d "enabled_events[]=customer.subscription.created" ...
```
All of this is **verified working** with a real org key: live webhook creation (response includes the `whsec_` signing secret — the exact operation the CLI's own live key refuses), customer writes, and the permission model behaving as scoped.
With the org key on file, a **new project's** Stripe surface shrinks to: ~5 dashboard clicks to create the org account (top of this guide) + grab the publishable key off the onboarding screen. Everything else — live webhook, signing secret, credentials — is agent work. No per-project key minting, no per-project `stripe login` (though logging in is still the convenient way to get sandbox/test keys for the `development` block).
**Where does the app's production secret key come from?** Two modes, honest trade-off:
- **Isolation mode** (default recommendation): a per-account restricted key (same permission list) for any project handling real volume — one dashboard step per project. A leaked key compromises one project.
- **Max-churn mode**: the app itself runs on the org key. `stripe-ruby` (which RailsFast ships) supports this natively — set the app's `stripe.context` credential and the initializer routes every call, no patches needed. Launch fast, but understand the exposure: one leaked project server exposes every account in the org (bounded by the key's scoped permissions and central revocability).
## The graduation policy
Max-churn mode needs a written exit rule, decided **before** the first project takes off — future-you under revenue pressure will not stop to think about key hygiene. The policy we use and recommend:
- **Launch every new project on the org key.** Speed is the point.
- **Graduate a project to its own per-account restricted key when ANY of these becomes true**: its first real customer charge · ~$500 MRR · ~100 customers. Whichever comes first.
- Graduation is a ~5-minute, mostly-agent task: mint the per-account restricted key in the dashboard (permission list above) → hand it over → swap `private_key`, delete the `context:` line → redeploy → verify with a live read.
- **Rotate the org key** on a calendar (semi-annually) and immediately on any suspected exposure — central revocation in the org dashboard kills it everywhere at once, which is precisely the feature.
- The org key lives in exactly two kinds of places: encrypted Rails credentials, and a `chmod 600` local file. Never in ENV files, CI settings, or anything plaintext.
---
## Updating RailsFast
Updating any template is always going to be a bit difficult, because as you start building your app on top of it many files will diverge from the original template, and conflicts will naturally arise. The worst-case scenario is you need to go file-by-file reviewing the template updates and deciding what to incorporate into your app.
HOWEVER, RailsFast is designed so the amount of effort you need to do is minimized, and ideally you can keep pulling RailsFast improvements into your app without much work.
> [!NOTE]
> This guide was battle-tested by upgrading a whole fleet of real production apps — including apps forked back on v0.x — with AI agents driving every merge, each one logging every snag. Everything below reflects what those upgrades actually hit.
## Make your life easier for updating
As we covered in the [[ui-components/9-customizing|Customizing Components]] section, the best practice if you plan to customize RailsFast components, is that you **don't edit the RailsFast components in-place**, instead, copy the component you want to change into your own folder and edit your copy.
Same idea for gems: the `Gemfile` ends with a "GEMS SPECIFIC TO THIS PROJECT" section — put your app's own gems there, not inside the template-owned regions, and your `Gemfile` stops conflicting on every future update.
## Protect your customizations (merge guards)
At some point, you will inevitably edit RailsFast core files. If you *did* edit files that ship with RailsFast and that you can't copy easily into your own folder (for example, layouts or auth views), you can tell Git to always keep *your* version on merges by adding merge guards in `.gitattributes`.
Example:
```gitattributes
# Keep my customized files when merging template updates
app/views/devise/** merge=ours
```
This way, if for example you update all your authentication screens to match your branding and design, when updating RailsFast your files will get priority, overriding the update, and you won't lose your work (you won't get updates on those files, though)
> [!CAUTION]
> **The `.gitattributes` line alone does nothing.** `ours` is not a built-in Git merge driver — the guard only works when the driver is configured:
> ```bash
> git config merge.ours.driver true
> ```
> `bin/setup` configures this for you, but a fresh clone on a new machine (or a CI checkout) doesn't have it — and then Git silently falls back to a normal merge on your "protected" files, in the direction of losing your customizations. Verify before any merge:
> ```bash
> git config --get merge.ours.driver # must print: true
> ```
Three more truths about guards, each learned on a real upgrade:
1. **Guards are silent.** A guarded file that kept your version prints an ordinary `Auto-merging` line — nothing tells you an upstream improvement was just discarded. That's why diffing guarded files is a standing step in [[#after-updating|After updating]], not a v0.x special.
2. **Guards don't cover deletions.** Merge drivers run only for *content* conflicts. If upstream **deletes** a file you've modified, you get a raw `CONFLICT (modify/delete)` that no guard resolves. Hard rule for that moment: **on any conflict touching `credentials.yml.enc`, `master.key`, or any `.key`/encrypted file — keep YOUR version, always.** The template cleaning up its own sample file is never a reason to delete your live credentials.
3. **Guard only what you must.** If you guard huge paths like `app/views/**`, you will also silently skip upstream improvements (including fixes) for those files.
## Before you update: figure out where you are
Three checks, one minute, and they prevent the worst classes of mistake:
```bash
# 1. Is this repo actually a fork of the template? (directory names lie)
git remote -v # is the railsfast remote there?
git fetch railsfast --prune --tags # fetch FIRST — and bring the release tags
git merge-base HEAD railsfast/main # a SHA = real shared ancestry; an error = STOP
# 2. Where did you *really* fork? (version constants on old apps can lie)
git tag --contains $(git merge-base HEAD railsfast/main) | head -3
# 3. Establish your baseline — run the gate BEFORE merging:
bin/ci
```
Fetch **before** you size up the job: measuring against a stale `railsfast/main` ref can overestimate the work several-fold. And the pre-merge `bin/ci` run is what lets you say *"the merge broke this"* versus *"this was already broken"* — if `main` is already red, fix that first, or you'll debug two problems tangled together.
## How to update RailsFast
**Work on a branch** — never merge the template directly into `main` on a production app:
```bash
git switch -c chore/template-update
```
Then merge. **Pinning a release is the reliable way** — merging `railsfast/main` gets you whatever has landed since the last release, which can be more than you bargained for:
```bash
git tag --list 'v*' --sort=-v:refname | head -5 # what releases exist?
git merge v1.0.0-beta.4 # pin a release (recommended)
# or: git merge railsfast/main # bleeding edge, eyes open
```
If you get conflicts, resolve them (Git will respect your merge guards — assuming the driver check above passed), and commit the merge. `bin/railsfast update` prints this whole flow tailored to your repo.
### Generated files: regenerate, don't hand-merge
`db/schema.rb` and `Gemfile.lock` conflict on almost every non-trivial update, and the correct resolution is never a careful hand-merge:
- **`Gemfile.lock`**: resolve the `Gemfile` conflict first, then `bundle install` — the lockfile regenerates itself.
- **`db/schema.rb`**: take your side, then `bin/rails db:migrate` applies the template's new migrations and re-dumps the schema authoritatively. Free correctness check: if you *did* hand-union it first, a **zero diff** after `db:migrate` proves your whole resolution was right.
### Port what the guards kept out
Guarded files keep your copy wholesale — which also means **new template keys and improvements never arrive in them on their own.** After merging, diff every guarded path against the release you merged, and port what's missing:
```bash
# the guard list lives in .gitattributes; for each merge=ours path:
diff <(git show v1.0.0-beta.4:config/railsfast/railsfast.yml) config/railsfast/railsfast.yml
diff <(git show v1.0.0-beta.4:config/application.rb) config/application.rb
diff <(git show v1.0.0-beta.4:.kamal/secrets) .kamal/secrets
# ...and so on down the .gitattributes list
```
This is mechanical, and it's the step that catches a new config key or an improved secrets expression that the guard silently discarded. Watch for **nested** keys — check paths, not just top-level names.
## Using AI agents to resolve conflicts
Merge conflicts are inevitable when you've customized template files and the upstream template has improvements to those same files. The good news: **AI coding agents like Claude Code excel at resolving these conflicts intelligently** — the fleet of real-app upgrades that road-tested this guide was agent-driven end to end.
Give the agent the *whole* protocol, not just the merge:
```
Update this app to RailsFast , on a branch, following
https://railsfast.com/docs/guides-updating/ exactly: verify
merge.ours.driver is configured, run a baseline bin/ci first, merge the
release tag, resolve conflicts (keep my customizations in app-owned
files; regenerate schema.rb and Gemfile.lock instead of hand-merging;
on anything touching credentials or .key files keep mine), diff every
merge-guarded file against the release and port missing keys, get
bin/ci green, run bin/railsfast doctor, then open a PR.
```
An agent given that prompt does the right thing. An agent told only to "merge and resolve conflicts, keep my styling" will skip exactly the steps this page says the merge cannot do by itself.
> [!TIP]
> Enable Git's `rerere` ("reuse recorded resolution") **before** merging — it records how you resolve each conflict and replays it if you ever redo the merge: `git config rerere.enabled true`
## After updating
In order — every step is here because skipping it bit a real upgrade:
```bash
bundle install # the update may have changed the Gemfile
bin/rails db:migrate # new template migrations
bin/ci # THE gate: style, audits, Brakeman, all tests, seeds
bin/railsfast doctor # production readiness — every finding carries its fix
bin/railsfast mark-updated # record the update so `status` tracks drift correctly
```
Also diff `config/bundler-audit.yml` against the release: if the template ships an advisory ignore, check whether a patched gem has shipped since — inheriting a suppression when the patch exists means you're suppressing, not patched.
Then restart your dev server (`bin/dev`), click around, and land your update branch via PR like any other change.
## What each release needs from you
- **v1.0.0-beta.4**: nothing manual — `stripe.context` (Stripe Organizations support) is opt-in, the backup `s3_endpoint.key` is optional, no migrations. If your `config/bundler-audit.yml` carries a `pay` advisory ignore, bump pay to ≥ 11.6.2 and drop it.
- **v1.0.0-beta.3**: the agent surface arrives (`bin/railsfast doctor`, the MCP server, the bundled Claude skill) — no manual steps.
- **From v0.x**: the big jump — next section.
## Upgrading to v1.0.0-beta (from v0.x)
The v1.0.0 betas are a big jump (Ruby 4, sessions & device security, the native server half, dark mode, organizations 0.5). A fleet of real apps has crossed it — the demo, railsfast.com itself, and several production apps.
**Run `bin/railsfast doctor` first, not last.** Old apps sometimes moved *ahead* independently (already on Ruby 4, already generated encryption keys) — doctor turns this checklist from "do all of this" into "verify, then act on what's actually missing":
1. **Active Record encryption keys are required in production.** `config/application.rb` fails closed on boot without them (fresh apps get them from `bin/setup`; upgraded apps don't — and doctor reports it as `ar-encryption-keys`). Generate and add them BEFORE deploying, or your first deploy dies at the health check:
```bash
bin/rails db:encryption:init
# copy the printed block into: EDITOR="cursor --wait" bin/rails credentials:edit
```
2. **Diff every merge-guarded file against the release** and port what's missing — the [[#port-what-the-guards-kept-out|mechanical step above]], which stays accurate as releases move (a hardcoded key list here would rot). One v0.x-specific find the diff will surface: delete the stale `template_version:` key from `config/railsfast/railsfast.yml` — the version's single source of truth moved to `lib/railsfast/version.rb`, and the guarded yml would keep the dead key forever.
3. **Ruby 4.0.5**: `rbenv install 4.0.5` / `mise install`, then `bundle install`. If you use a remote builder, update `RUBY_VERSION` in `config/deploy.yml`'s builder args — and check your Dockerfile's `ARG RUBY_VERSION` if you've customized it.
4. **`bin/rails db:migrate`** (sessions tables + organizations 0.5).
If you customized Stimulus-driven markup: the template reorganized all controllers under `railsfast/` and `app/` ownership folders, so old root-level identifiers (`navbar`, `dropdown`, `slideover`, …) are now `railsfast--layout--navbar`, `railsfast--ui--dropdown`, and so on — update any `data-controller` attributes in views you own.
---
## Beyond credits: wallets for games, telecom, and marketplaces
RailsFast's [usage credits system](/features-14-usage-credits/) (opt-in) is built on a powerful ledger engine. If your product needs more than simple API credits — like game currencies, mobile data plans, or marketplace balances — you can use the underlying [`wallets`](https://github.com/rameerez/wallets) gem directly (also opt-in: uncomment `gem "wallets"` in the `Gemfile`).
## When to go beyond credits
| If you need... | Use |
|----------------|-----|
| API usage billing | `usage_credits` (opt-in) |
| Multiple currencies per user | `wallets` |
| User-to-user transfers | `wallets` |
| Game resources (wood, gold, gems) | `wallets` |
| Marketplace seller balances | `wallets` |
| Mobile data / telecom plans | `wallets` |
## Quick comparison
```
┌─────────────────────────────────────────────────────────────┐
│ usage_credits │
│ "User spends credits on operations" │
│ │
│ user.spend_credits_on(:generate_image) │
│ user.credits → 4,950 │
│ │
│ Built on wallets — wallet-level transfers available: │
│ user.credit_wallet.transfer_to(friend.credit_wallet, 100) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ wallets │
│ "User has balances that move around" │
│ │
│ user.wallet(:gold).transfer_to(friend.wallet(:gold), 100) │
│ user.wallet(:gold).balance → 900 │
│ friend.wallet(:gold).balance → 100 │
└─────────────────────────────────────────────────────────────┘
```
## Example: Mobile data app (eSIM cards)
Users get monthly data and can share unused GBs with friends:
```ruby
# app/models/user.rb
class User < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :data_mb # Store in MB for precision
end
```
```ruby
# Grant monthly data (10 GB = 10,240 MB)
user.wallet(:data_mb).credit(
10_240,
category: :monthly_plan,
expires_at: 1.month.from_now
)
# Network usage depletes balance
user.wallet(:data_mb).debit(512, category: :network_usage)
# User shares 3 GB with a friend
user.wallet(:data_mb).transfer_to(
friend.wallet(:data_mb),
3_072,
category: :data_gift
)
user.wallet(:data_mb).balance # → 6,656 MB remaining
```
## Example: Game economy (FarmVille-style)
Multiple resources, trading between players, seasonal events:
```ruby
class Player < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :gold
end
```
```ruby
# Quest rewards multiple resources
player.wallet(:wood).credit(100, category: :quest_reward)
player.wallet(:stone).credit(50, category: :quest_reward)
player.wallet(:gold).credit(25, category: :quest_reward)
# Crafting consumes resources
player.wallet(:wood).debit(30, category: :crafting)
# Premium currency from in-app purchase
player.wallet(:gems).credit(500, category: :iap_purchase)
# Seasonal currency that expires
player.wallet(:snowflakes).credit(
1_000,
category: :winter_event,
expires_at: Date.new(2024, 1, 7)
)
# Trading between players
player.wallet(:gold).transfer_to(
other_player.wallet(:gold),
100,
category: :trade
)
```
## Example: Marketplace (Etsy/Fiverr-style)
Seller balances, platform fees, payouts:
```ruby
class User < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :usd_cents # Always use cents for money
end
```
```ruby
# Order completed — credit seller minus platform fee
order_total = 5000 # $50.00
platform_fee = (order_total * 0.10).to_i
seller_earnings = order_total - platform_fee
seller.wallet(:usd_cents).credit(
seller_earnings,
category: :sale,
metadata: {
order_id: order.id,
gross: order_total,
fee: platform_fee
}
)
# Seller requests payout
seller.wallet(:usd_cents).debit(
seller.wallet(:usd_cents).balance,
category: :payout,
metadata: { stripe_transfer: "tr_xxx" }
)
# Buyer uses store credit
buyer.wallet(:usd_cents).debit(2000, category: :purchase)
```
## Example: Loyalty programs & Reward points
Whether you're building a Starbucks-style loyalty program, credit card rewards, airline miles, or a Sweatcoin-style earn-from-actions app — it's the same pattern:
```
┌─────────────────────────────────────────────────────────────┐
│ Loyalty program flow │
├─────────────────────────────────────────────────────────────┤
│ EARN │ Purchase, action, referral, promo │
│ HOLD │ Points accumulate, some may expire │
│ TRANSFER │ Gift to family, pool with friends │
│ REDEEM │ Rewards, discounts, gift cards │
└─────────────────────────────────────────────────────────────┘
```
```ruby
class User < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :points
end
# ═══════════════════════════════════════════════════════════
# EARN — from purchases, actions, referrals
# ═══════════════════════════════════════════════════════════
# Points from purchase (1 point per dollar)
user.wallet(:points).credit(order.total_cents / 100, category: :purchase)
# Referral bonus
user.wallet(:points).credit(500, category: :referral)
# Daily check-in streaks
user.wallet(:points).credit(50, category: :daily_checkin)
# Receipt scanning (Ibotta-style)
user.wallet(:points).credit(100, category: :receipt_scan)
# ═══════════════════════════════════════════════════════════
# EXPIRING PROMOS — use-it-or-lose-it campaigns
# ═══════════════════════════════════════════════════════════
# Welcome bonus that expires in 30 days
user.wallet(:points).credit(500, category: :welcome_bonus, expires_at: 30.days.from_now)
# Double points weekend
user.wallet(:points).credit(200, category: :promo, expires_at: Date.current.next_occurring(:monday))
# Birthday reward
user.wallet(:points).credit(1000, category: :birthday, expires_at: 1.month.from_now)
# ═══════════════════════════════════════════════════════════
# TRANSFER — gift to friends, pool with family
# ═══════════════════════════════════════════════════════════
# Gift points to another member
user.wallet(:points).transfer_to(friend.wallet(:points), 500, category: :gift)
# Family pooling
family_members.each do |member|
member.wallet(:points).transfer_to(family_pool.wallet(:points), member.wallet(:points).balance, category: :family_pool)
end
# ═══════════════════════════════════════════════════════════
# REDEEM — rewards, discounts, cash out
# ═══════════════════════════════════════════════════════════
# Redeem for a reward
user.wallet(:points).debit(2500, category: :redemption, metadata: { reward: "free_coffee" })
# Redeem for gift card
user.wallet(:points).debit(10_000, category: :cash_out, metadata: { gift_card_value: 1000 })
```
**This pattern fits:**
- Starbucks Stars, Dunkin' Rewards
- Airline miles (Delta SkyMiles, United MileagePlus)
- Credit card points (Chase Ultimate Rewards, Amex MR)
- Hotel points (Marriott Bonvoy, Hilton Honors)
- Retail loyalty (Sephora Beauty Insider, REI Co-op)
- Cashback apps (Rakuten, Ibotta, Fetch)
- Fitness rewards (Sweatcoin, Stepn)
## Transfer expiration policies
When transferring between users, what happens to expiration dates?
By default, transfers **preserve expiration buckets**. If Alice transfers credits from multiple buckets with different expirations, Bob receives multiple inbound credit transactions so those expirations remain intact.
| Policy | Behavior | Best for |
|--------|----------|----------|
| `:preserve` (default) | Keeps source bucket expirations | Loyalty points, seasonal currencies |
| `:none` | Receiver gets evergreen credits | Money, store credit |
| `:fixed` | Explicit `expires_at` on receive | Promotional transfers |
```ruby
# Default — preserve expirations from source buckets
alice.wallet(:points).transfer_to(bob.wallet(:points), 100)
# Evergreen on receive (no expiration)
alice.wallet(:usd_cents).transfer_to(
bob.wallet(:usd_cents),
500,
expiration_policy: :none
)
# Fixed expiration on receive
alice.wallet(:promo_credits).transfer_to(
bob.wallet(:promo_credits),
200,
expiration_policy: :fixed,
expires_at: 30.days.from_now
)
```
## Using both together
You can use `usage_credits` for your API billing AND `wallets` for other balances in the same app:
```ruby
class User < ApplicationRecord
has_credits # API credits via usage_credits
include Wallets::HasWallets # Additional wallets
has_wallets default_asset: :reward_points
end
```
```ruby
# API operations use credits
user.spend_credits_on(:generate_report)
# Rewards use wallets
user.wallet(:reward_points).credit(100, category: :signup_bonus)
```
They're completely isolated — different tables, different configs, no conflicts.
## Going deeper
- [`wallets` gem documentation](https://github.com/rameerez/wallets) — full API reference
- [`usage_credits` gem documentation](https://github.com/rameerez/usage_credits) — for API/SaaS billing
The key insight: **wallets is for holding and moving value**, **usage_credits is for selling and consuming value**. Pick the right tool for your use case.
---
Back to [[features/14-usage_credits|Usage credits]] for the standard API billing setup.
---