First Deploy Checklist
Secrets, the master key, and what's required versus optional before you deploy.
The very first bin/kamal deploy needs less than you'd expect: a Rails master key and a container
registry token. Everything else, Stripe, Resend, S3, is genuinely optional at this stage, since the
app runs on its Local fakes until you configure real credentials. See
Bring Your Own Keys for the full reference.
What secrets actually need to exist
Kamal's secrets file, .kamal/secrets, reads each value from a gitignored local file, falling back
to an environment variable, which is how CI injects them. For a local bin/kamal deploy, the files
worth creating are:
| File | Value |
|---|---|
config/master.key |
Your Rails master key |
.kamal/.registry-token |
A container registry token with write access, for pushing the built image |
.kamal/.stripe-secret-key |
Stripe live secret key, optional |
.kamal/.stripe-webhook-secret |
Stripe webhook signing secret, optional |
.kamal/.resend-api-key |
Resend API key, optional |
The very first deploy needs only the master key and the registry token. Set a real price id in
config/deploy.yml before you turn on billing for real; see
Stripe Setup.
Required before production will even boot
Rails will not boot in production without a secret key base; it signs sessions and cookies, and there's no safe default the way development and test have. This is the most common first-deploy failure precisely because it doesn't show up locally: dev and test each generate a temporary key automatically, so a fresh clone runs fine right up until the first real deploy fails. See Secret Key Base for how to generate one.
The Stripe webhook events, if you're turning on billing
If you enable real Stripe billing, your webhook endpoint needs specific events enabled, not just "all events." A handler exists for each of these, and an event that isn't enabled simply never fires, with no error anywhere to notice: entitlement quietly stops tracking reality.
| Event | What breaks without it |
|---|---|
checkout.session.completed |
Purchases don't grant access (the return-URL redirect covers some of this, but only when the buyer actually lands there) |
invoice.payment_succeeded |
Renewals don't extend access |
invoice.payment_failed |
Failed renewals don't enter a past-due grace window |
customer.subscription.updated |
Plan changes and cancellations don't sync; the UI keeps saying "renews" |
customer.subscription.deleted |
Access isn't revoked when a subscription actually ends |
charge.refunded |
A refunded buyer keeps access |
charge.dispute.created |
A buyer who charges back keeps access |
radar.early_fraud_warning.created |
A charge the card issuer already flagged as fraudulent turns into a full dispute you could have avoided |
An early fraud warning is the issuer flagging a charge as fraudulent before the cardholder actually disputes it. Revoking access and refunding immediately in that window usually means no formal dispute ever gets filed, which avoids both a dispute fee and a mark against the dispute ratio card networks judge your account on. This is also why the Stripe webhook endpoint is exempt from the app's general per-IP rate limit: Stripe delivers from a small pool of IPs and can auto-disable an endpoint that keeps returning non-2xx responses, and a throttled webhook fails both silently and completely.
A daily job re-checks lapsed subscriptions against Stripe, so a single missed webhook delivery self-heals on its own. It can't cover refunds, disputes, or fraud warnings, though; none of those have a lapse date for the job to notice, which is exactly why those specific events need their own handlers rather than relying on the daily sweep.
Verify
bin/kamal setup
bin/kamal logs -f
Then confirm the app answers, both on the server directly and through your public domain, before you consider the deploy done.
Next
Get your domain pointed at the server: Edge and DNS Setup.