Configure credentials
The next thing that's stopping us from charging money and delivering our software service right away is configuring the app to use our credentials.
We need, for example, to configure our Stripe API keys in the project so we can start processing payments; or our S3 access keys so we can upload and save attachments like user avatars.
Credentials are secure and encrypted
Credentials in RailsFast are encrypted. They can only be decrypted with the random encryption key (config/master.key) that was generated uniquely for you upon project setup (when we ran bin/railsfast init in the quickstart). This is a secure way to store private & sensitive credentials like access and API keys, without risking leaking them in plaintext in the codebase. No one can read your credentials even if they get access to the encrypted file (credentials.yml.enc), because they don't have the decryption key.
Never commit or make public your master.key file. It is already in the .gitignore to avoid it getting accidentally commited, so don't change that. Use the same caution for any other .key file in the project.
How to edit the encrypted credentials file
The RailsFast setup also generated a sample credentials file for you, to help you understand how the credentials file should be used in your project.
You'll see it's very straightforward. To view and edit your project credentials, run:
EDITOR="cursor --wait" bin/rails credentials:edit
And you'll view the decrypted credentials, ready to edit.
To save changes after editing, just save the file and close it, and the credentials will automatically get encrypted back, ready to be used by your app.
Terminal / agent lane: credentials:edit re-encrypts whatever text your $EDITOR leaves in the tempfile, so a script can be the editor, and the edit can be fully non-interactive:
# set-stripe-key.rb: surgical TEXT substitution on the decrypted tempfile
# text = File.read(ARGV[0])
# abort("pattern not found, file untouched") unless text.sub!("private_key: sk_live_abc123", "private_key: #{ENV.fetch("STRIPE_KEY")}")
# File.write(ARGV[0], text)
EDITOR="ruby set-stripe-key.rb" bin/rails credentials:edit
The one iron rule: treat the decrypted credentials as text, never as data. Loading the YAML into a hash and dumping it back destroys every comment, every &anchor, and every <<: merge in the file, irrecoverably. A text-substitution edit like the one above is verified byte-identical everywhere except the line it changes. Have the script abort (non-zero, before writing) when its target pattern isn't found: aborting without writing leaves the file untouched, so a mismatched assumption can never half-edit your credentials. Read credentials with bin/rails credentials:show, never by decrypting files yourself.
If a round-trip already flattened your file (comments gone, the shared: block duplicated under every environment, stray auto-generated anchors; the file still works, which is why nobody notices): rebuild it against config/credentials.sample.yml. Recreate the sample's structure, carry every real value over, then verify the resolved hash (with <<: merges applied) of the new text equals the old one before writing it back through the editor lane above.
How the credentials file is organized
If you look at the credentials file, you'll see it's organized by environments: shared, production, and development.
You may (and probably should) have different credentials for your development and production environments. For example, for Stripe, you should use your sandbox / test keys in development; and you should only use the live keys in production, exclusively.
"Production" just means "your server": the environment where your project is live and ready to be used by other people. As opposed to "development": where the project is only running in your local development computer, and only accessible by you.
For some other services, this production vs development distinction may not make much sense, so to avoid duplication you can just put those credentials under the shared block, and both production and development will get them automatically.
I've included a sample demoservice to showcase how shared credentials work. You can also override any part of a shared credentials block like demoservice per environment, while the rest will be kept the same. In any case, demoservice is just there for demoing purposes: you can remove it.
Your credentials also include an active_record_encryption: block with keys bin/setup generated for you. You don't need to touch it. See Encrypting sensitive data for what it's for and how it works.
Add all the necessary credentials
If you followed the Prerequisites section, you should already have accounts in all the key services we're going to use, so now it's just a matter of going service by service generating the right keys and plugging them in the right place.
For your RailsFast app to fully work, we'll need at least:
- AWS: we'll just use AWS SES for sending emails. I'm not a fan of AWS, but SES is probably the cheapest & most reliable emailing service out there.
- Inside your AWS Dashboard, navigate to
AWS SES(Simple Email Service)- Add your domain in SES > Configuration > Identities and create all required DNS records
- For more detailed instructions, follow our perfect emailing guide and you'll also achieve a 10/10 deliverability score in your emails
- Now move away from SES and navigate to AWS IAM to generate an Access Key and Secret Access Key for SES
- Create a new IAM user
- Name it something descriptive like
yourprojectname-production - Give it enough SES permissions to send emails
- To do that, create a new policy, click on the [ Create policy ] button
- In the "Specify permissions window, switch from "Visual" to "JSON" and paste the following contents:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "ses:SetIdentityMailFromDomain", "ses:SendEmail", "ses:SendTemplatedEmail", "ses:SendCustomVerificationEmail", "ses:SendRawEmail", "ses:SendBulkTemplatedEmail", "ses:SetIdentityDkimEnabled", "ses:SetActiveReceiptRuleSet" ], "Resource": "*" } ] }- Give it a descriptive name like
ses-send-email-policyand create the policy
- Back on the create user screen, attach the new created policy to the user, and create it
- Once the user is created, click it to access the user details screen, navigate to the "Security credentials" tab, and create a new Access Key. When asked, select "Other" as your reason (your reason is direct AWS API calls, but that's not listed)
- Create the Access Key and copy the access key and private key to your RailsFast project's credentials file
- Inside your AWS Dashboard, navigate to
Terminal / agent lane: the entire AWS SES setup is CLI-able, with no dashboard needed. With an authenticated aws CLI: aws iam create-policy (the JSON above) β aws iam create-user β aws iam attach-user-policy β aws iam create-access-key (straight to a chmod 600 file, never echoed). Then the identity: aws sesv2 create-email-identity --email-identity myproject.com --dkim-signing-attributes NextSigningKeyLength=RSA_2048_BIT (returns the three DKIM tokens) and aws sesv2 put-email-identity-mail-from-attributes --email-identity myproject.com --mail-from-domain mail.myproject.com --behavior-on-mx-failure USE_DEFAULT_VALUE. The DNS records from the deliverability guide (3Γ DKIM CNAME, the MAIL FROM MX + SPF TXT, DMARC TXT) go in via the Cloudflare API, the same pattern as the quickstart DNS lane, all DNS-only (not proxied). Verified in ~2 minutes.
One gate to check first: aws sesv2 get-account --query ProductionAccessEnabled. A fresh AWS account starts in the SES sandbox (can only send to verified recipients). Requesting production access is a human step (support case, up to 24h); in the meantime aws sesv2 create-email-identity --email-identity [email protected] verifies individual recipients for testing.
- Cloudflare: DNS + DDoS protection, Turnstile for anti-bot signups, R2 for S3-compatible storage (for storing avatars, user uploads, etc.)
- R2: create an R2 bucket for your project. Then, on the R2 Overview screen, under the Account Details box, you'll see the "API Tokens" row and next to it, a
{Manage}button, click it. Now in the Manage R2 API tokens screen, create a User API Token for your bucket (give itObject Read & Writepermissions, click "Apply to specific buckets only" and specify just your bucket). Create the API Token and copy and paste theaccount_id,access_key_id,secret_access_keyand bucket name, and paste them in the right place in your credentials file. Go back to the R2 Overview screen, copy your Cloudflare account ID and paste it in the corresponding spot in the credentials file too. You only need to do this forproduction, not fordevelopment. - Turnstile: create a Turnstile widget to get your Turnstile site key and secret key. This will be used to protect your auth / signup forms from bots. As with R2, Turnstile credentials are only needed in
production, not indevelopment. You just need the site and secret keys, you don't need to do anything else with the widget, all is already configured and wired in RailsFast for you.
- R2: create an R2 bucket for your project. Then, on the R2 Overview screen, under the Account Details box, you'll see the "API Tokens" row and next to it, a
Terminal / agent lane: R2 + Turnstile take exactly one human step: an account-scoped API token (Cloudflare dashboard β profile β API tokens β Custom token β Account β Workers R2 Storage β Edit + Account β Turnstile β Edit), handed over via the secret handoff pattern. Everything else is three API calls:
CFA="$(cat ~/.config/railsfast/cloudflare_account_token)"
# 1. Create the bucket (locationHint is advisory: Cloudflare may place the
# bucket in a nearby region; options: weur/eeur/wnam/enam/apac):
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/r2/buckets" \
-H "Authorization: Bearer $CFA" -H "Content-Type: application/json" \
--data '{"name":"myproject","locationHint":"weur"}'
# 2. The token IS your S3 credential pair (documented R2 pattern):
# access_key_id = the token's ID: curl -s .../user/tokens/verify | jq -r .result.id
# secret_access_key = SHA-256 of the token value: printf '%s' "$CFA" | shasum -a 256
# 3. Create the Turnstile widget (returns sitekey + secret):
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets" \
-H "Authorization: Bearer $CFA" -H "Content-Type: application/json" \
--data '{"name":"myproject","domains":["myproject.com"],"mode":"managed"}'
Then write the values into the production block non-destructively (see the credentials tip above).
The cf CLI has R2 commands (cf r2 buckets create) but no Turnstile command. Without a token that has Turnstile β Edit, the widget is a dashboard step (Abuse protection). Until it exists, leave the turnstile: block out of the credentials: the sample keys turn Turnstile on and block every sign-up.
-
Google (optional): one-click "Continue with Google" sign-in on your signup and login forms. Skip it and nothing breaks: the feature simply stays off, buttons and all.
- In the Google Cloud console, create an OAuth client ID (application type:
Web application). One client powers both the button and Google One Tap, so register:- Authorized JavaScript origins:
https://YOUR-DOMAIN-HERE(andhttp://localhostplushttp://localhost:3000for development) - Authorized redirect URIs:
https://YOUR-DOMAIN-HERE/users/auth/google_oauth2/callback(the button) andhttps://YOUR-DOMAIN-HERE/users/auth/google_one_tap(One Tap)
- Authorized JavaScript origins:
- Paste the client ID and client secret into the
google:βoauth:block of yourproductioncredentials. Add the same block todevelopment(with thehttp://localhost:3000/...redirect URIs) if you also want it while developing - Full walkthrough, including the consent screen and how account linking works: Social login
- In the Google Cloud console, create an OAuth client ID (application type:
-
Stripe: payment processing
- Generate a publishable and a private key (both in live and sandbox mode, for
productionanddevelopment, respectively) - Create a webhook in live mode (webhook URL:
https://YOUR-DOMAIN-HERE/pay/webhooks/stripe), and add the webhook signing secret inproduction. Stripe pins an API version on each endpoint when you create it. Use a version from2025-03-31onward (a new endpoint normally gets the current one); on an older endpoint Pay skips the payment-failed email and the invoice sync. See Payments. - Install the Stripe CLI and add the development webhook signing secret to
development
- Generate a publishable and a private key (both in live and sandbox mode, for
Terminal / agent lane: after a human runs stripe login once (browser step), the CLI holds test + live publishable/restricted keys in ~/.config/stripe/config.toml (chmod 600: agents read it, never echo it). From there: development credentials are fully agent-obtainable (test keys from the config file, dev webhook secret via stripe listen --print-secret); sandbox and live webhook endpoints are one CLI call each (stripe webhook_endpoints create --url https://yourdomain.com/pay/webhooks/stripe ...; the response includes the whsec_ signing secret). The one per-account human step for production: a dashboard-created restricted key for the app (the CLI's own live key expires in 90 days and can't do live writes, by design). Running several projects? Read the Stripe Organizations guide: one org, one account per project (copying your legal entity, so no repeat KYC), and optionally one sk_org_ key that automates every project account.
- Push notifications (optional): only if you ship the native iOS and Android apps and want to send them notifications. Leave it out and push simply stays off.
- Secrets go in the environment's credentials block, under
action_push_native::apns.key_idandapns.encryption_key(the contents of the.p8key from Apple Developer β Keys), andfcm.encryption_key(the Firebase service-account JSON). Values still ending inabc123count as unset. - The non-secret half: the APNs team id and topic come from the iOS app id you already configured for app links (
APNS_TOPICoverrides the topic), and the Firebase project id comes fromANDROID_FIREBASE_PROJECT_IDornative.android.firebase_project_idinconfig/railsfast/railsfast.yml. bin/railsfast doctorlists both as optional services. The full setup, including the migrations and how to send a notification: Notifications.
- Secrets go in the environment's credentials block, under
Configure database backups
RailsFast ships by default with automatic database backups. All your user's data gets backed up regularly outside of your server, so in case of disaster you can recover all your data and put your app back up. However, for this to work you need to let your app know where those backups should be uploaded to.
Luckily for us, we can use any S3-compatible storage solution for this. So, since we already have an AWS and a Cloudflare account, we could just use AWS S3 or Cloudflare R2 for our backups and be done with it. There's nothing wrong with that.
However, if you're like me and you want to add an extra bit of resilience to your setup (in the improbable case your AWS or Cloudflare accounts get compromised because you're already using them for other things), you can keep your backups in a completely different service that we're only going to use for backups. I use Backblaze B2 for that. Backblaze B2 is S3-compatible just like AWS S3 or Cloudflare R2, and they also have a free tier that makes your backups free or close to free as long as they're not too big.
Whatever the service you end up using for storing backups (again, AWS S3 and Cloudflare R2 are both good options if you don't want to complicate things early), you just need to add the S3-compatible credentials in the designated files under config/backups:
s3_access_key.keys3_bucket.keys3_region.keys3_secret_access_key.key
These credentials are the same for any S3-compatible storage service. Just replace the placeholder text in each file with your credentials, re-deploy the backups service with kamal accessory reboot pg-backup, and you'll be automatically backing up your entire production database every night.
Using S3 or R2 instead of Backblaze B2? One more thing: the upload endpoint defaults to a B2 hostname. On current templates, write your provider's endpoint into an optional config/backups/s3_endpoint.key (S3: s3.us-east-1.amazonaws.com; R2: <account_id>.r2.cloudflarestorage.com) and it takes precedence. On older templates (no s3_endpoint.key support in .kamal/secrets), edit the HOST_BUCKET= line in .kamal/secrets directly. Otherwise your backups will silently target the wrong host.
Verify your backups by RESTORING one, not by listing the bucket. A dump in S3 proves upload; only a completed restore proves recovery. Trigger a backup manually, then walk the restore runbook against a scratch database (about fifteen minutes, once), and you'll actually know:
bin/kamal accessory exec pg-backup --reuse "/backup-scripts/backups.sh"
# confirm the dumps landed (B2 endpoint; <region> is the value in config/backups/s3_region.key)β¦
aws s3 ls s3://your-backups-bucket --recursive \
--endpoint-url https://s3.<region>.backblazeb2.com --region <region>
# β¦then do the scratch-database restore drill from the runbook guide
If you want to adjust when to do backups, how often to do them, and how many backups to keep, you just need to adjust the pg-backup config in config/deploy.yml. Make sure to reboot the backups service after you make changes:
bin/kamal accessory reboot pg-backup
We're using the kartoza/pg-backup Docker image for database backups. It's already wired up and configured to work with your credentials. It's also configured as a Kamal accessory so you can easily manage it as shown above. Read their docs to know more about available backup options.
Verify your backups are actually running
A backup pipeline can die without telling anyone: the container gets rebooted and never comes back, credentials rot, or the cron runs but uploads a tiny garbage file instead of a real dump. As a cautionary tale, a production app once ran seven months with silently dead backups. Every dashboard was green the whole time, because nothing that reported health shared the backup container's fate. Nobody deletes their database on a day their backups happen to be working; the day you need a dump is precisely a day nothing else went right.
That's why current templates ship a backup tripwire: an allgood check called "Database backups are fresh" on your /healthcheck page (the code lives in app/services/backup_freshness.rb). The app itself lists the backup bucket (the listing is cached for 30 minutes) and goes red when the newest main database dump is older than 26 hours (BACKUP_MAX_AGE_HOURS) or not bigger than 1 MB (BACKUP_MIN_BYTES: real dumps are megabytes; a "successful" run that uploads a 35-byte metadata file is a dead backup wearing a green light). Since your uptime monitor is already watching /healthcheck, a dead backup pipeline now pages you within a day instead of seven months later. And if the tripwire itself dies, it takes the healthcheck page with it, so its own death is visible too.
To arm it, the check needs its own key that can only list file names. Do not reuse the backup credentials above. In Backblaze B2 (equivalent steps exist in S3/R2):
- Application Keys β Add a New Application Key
- Allow access to Bucket(s): your backup bucket only
- File name prefix: your app's backup prefix (your app name, e.g.
myapp/) - Capabilities:
listFilesonly: no read, no write, no delete - Save the two values into gitignored files: the keyID into
config/backups/monitor_key_id.keyand the applicationKey intoconfig/backups/monitor_application_key.key, then deploy..kamal/secretsalready reads both files.
Why a listFiles-only key instead of the backup credentials the app⦠wait, the app never had them. Keep it that way. The pg-backup accessory's read/write credentials never enter your app container, and the tripwire must not change that. An app holding full bucket keys turns any app compromise into a backup-bucket compromise: if you keep several projects' backups in one bucket (the default PROJECT_BACKUP_PREFIX setup), that's every project's customer data exfiltrated in one go, plus the ability to delete the backups, exactly the leverage a ransomware operator wants before touching your database. A listFiles-only key caps the blast radius at "an attacker learns your backup filenames."
The check fails closed: with no monitor key configured, it reports failing on /healthcheck in production rather than staying silently green. The deploy itself still goes through, because .kamal/secrets falls back to an empty value while the two key files are missing, so expect your uptime monitor to flag the check until step 5 is done. That's deliberate: an unarmed tripwire that looks armed is worse than none.
The tripwire proves your backups keep existing. It doesn't prove they restore; that's what the drill in the tip above is for: do the scratch-database restore once, then let the tripwire watch the pipeline day to day.