Answers · Deploying

Why do my app's emails link to localhost after deploying?

A mailer runs in a background job with no incoming request, so it cannot infer your domain. It reads APP_HOST instead, and without that set it falls back to the development default. Set APP_HOST to your domain, redeploy, and every mailer link, canonical tag and sitemap URL becomes absolute and correct.

Set it and confirm it arrived

bin/kamal app exec 'bin/rails runner "puts Rails.application.config.x.app_host"'

If that prints nothing, the variable is not reaching the container. APP_HOST goes in the env section of config/deploy.yml rather than in .kamal/secrets, because it is not a secret: it is your public domain name.

Why this only breaks in production

In development every link you click carries a request, and Rails builds URLs from it. A mailer is different: deliver_later enqueues a job, Solid Queue runs it minutes later in a different process, and there is no request anywhere in that path. The host has to come from configuration.

That is also why the failure is invisible in the test suite. Request specs supply a host, so links look right, and the first broken link is the first real sign-in email a user receives.

The blast radius is wider than email

The same setting feeds several things that all need an absolute URL and none of which have a request:

  • Magic-code sign-in links, which is the one users hit immediately.
  • <link rel="canonical"> on every public page.
  • /sitemap.xml, /feed.xml and /llms.txt, which are built by app/controllers/seo_controller.rb.

A sitemap full of localhost URLs is submitted successfully and indexes nothing, with no error anywhere.

Verify from outside

curl -s https://yourdomain.com/sitemap.xml | head -5

The first <loc> should be your domain. That single check covers the mailer case too, because both read the same configured host.

Do not hardcode it in a mailer

It is tempting to pass host: into one *_url call and move on. That fixes one link and leaves the other dozen, and it puts the domain in application code where a rebrand has to find it. One setting, read everywhere, is the shape that stays correct.

Related questions

Do I need API keys to run a Rails starter kit locally?

No. Billing, email, bot checking and error reporting each sit behind an adapter that returns a real client when its credential is present and a deterministic local fake when it is not. A fresh clone runs bin/setup and bin/dev with no keys, and checkout, sign-in and gated features all work end to end.

How do I stop bot signups on a Rails sign-up form?

Set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. app/adapters/bot_check.rb returns a real Cloudflare Turnstile verifier when both are present and a pass-everything fake when they are not, so the form works in development with no account and starts blocking bots in production without a code change.

How do I store uploaded files on S3 instead of local disk?

Set S3_BUCKET, S3_REGION, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, plus S3_ENDPOINT for a non-AWS provider. config/storage.yml already defines the service, so production uses object storage instead of the container's disk. Existing files on local disk do not move by themselves.