Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Secrets never reach templates, SSE, JSON or logs:
Only when the developer asks: it creates billed resources in their Railway account.

1. **Preflight.** `bin/railway-setup` is built by any `make railway-*` target. Run `make railway-doctor ARGS=--json` and fix every check with `"status": "fail"`; each check carries a `fix`. Typical fixes: `railway login` (the developer's browser), `--workspace` when they belong to several, and `railway ssh keys add` when there is more than one environment.
2. **Setup.** `make railway-setup ARGS="--yes --json --environments staging,production"`. Add `--admin true`, `--region`, `--project` or `--workspace` as asked. Every flag has a `PLATFORM_*` env equivalent (`PLATFORM_YES=1`, `PLATFORM_JSON=1`, `PLATFORM_ENVIRONMENTS`, ...). The flags are written into `deploy/platform.yaml`: commit that file. Without `--yes`, and with no terminal to ask on, creating a project stops with exit 3.
2. **Setup.** `make railway-setup ARGS="--yes --json --environments staging,production --email-from noreply@<their domain>"`. A real sender is required: plan, setup and apply refuse an unset or example `email.from` (production refuses to boot with one), and `nextSteps` says so. Ask the developer for the sender on their verified email domain; never invent one. Add `--admin true`, `--region`, `--project` or `--workspace` as asked. Every flag has a `PLATFORM_*` env equivalent (`PLATFORM_YES=1`, `PLATFORM_JSON=1`, `PLATFORM_ENVIRONMENTS`, ...). The flags are written into `deploy/platform.yaml`: commit that file. Without `--yes`, and with no terminal to ask on, creating a project stops with exit 3.
3. **Read the summary.** stdout is one JSON object: `ok`, `project.{id,url}`, `source` (`github` or `upload`), each environment's `branch` (with `github`: production `main`, staging and qa `dev` unless `branches` says otherwise) and services with `url`, `deployment` and `health`, and `nextSteps`. Logs go to stderr. Relay `nextSteps` to the developer verbatim. They are things only a person can do, such as installing the GitHub app, Doppler tokens or a real `RESEND_API_KEY`. Never invent or fill in secrets yourself.
4. **Doppler** (`secrets.provider: doppler`): when the developer has run `doppler login`, setup also moves the app secrets into Doppler ([docs/secrets.md](docs/secrets.md#secrets-and-doppler)); otherwise `nextSteps` says so, and `make doppler-setup ARGS="--json"` does it later. Its output names configs, secret names and fingerprints only. Keep it that way: never print, log or paste a token or secret value.
5. **Hand-set values.** Never set a Railway variable by hand without listing it in `unmanaged_variables` or as `manual` in `per_environment` ([docs/deploy.md](docs/deploy.md#hand-set-variables)): the next apply deletes it. `make deploy-vars` must pass.
Expand Down
5 changes: 5 additions & 0 deletions deploy/cmd/railway-setup/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ func (setup *setup) doctor(ctx context.Context) error {
return withCode(exitPreflight, "preflight failed")
}
setup.report(check{Name: "railway cli", Status: "ok", Detail: strings.TrimSpace(firstLine(string(who)))})
if problems := setup.cfg.SenderProblems(); len(problems) > 0 {
fail(check{Name: "email.from", Detail: strings.Join(problems, "; ") + ": production refuses to boot without a real sender", Fix: senderFix})
} else {
setup.report(check{Name: "email.from", Status: "ok", Detail: setup.cfg.Email.From})
}

if ws, err := setup.workspaceID(ctx); err != nil {
fail(check{Name: "workspace", Detail: err.Error(), Fix: "set workspace in deploy/platform.yaml or pass --workspace / PLATFORM_WORKSPACE"})
Expand Down
39 changes: 39 additions & 0 deletions deploy/cmd/railway-setup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ func (setup *setup) main(cmd, config, iac, only string, wait time.Duration, ovs
func (setup *setup) reconcile(ctx context.Context, wait time.Duration) error {
setup.say("platform.yaml: project %q, environments %v, admin %v, previews %v, secrets %s, repo %q",
setup.cfg.Project, []string(setup.cfg.Environments), setup.cfg.Admin, setup.cfg.Previews, setup.cfg.Secrets.Provider, setup.cfg.Repo)
if err := setup.requireSender(); err != nil {
return err
}
if err := setup.ensureProject(ctx); err != nil {
return err
}
Expand Down Expand Up @@ -368,6 +371,42 @@ func (setup *setup) dopplerPlaceholders(ctx context.Context) []string {
return out
}

// senderFix is how to give the apps a real sender.
const senderFix = "set email.from in deploy/platform.yaml to a sender on your verified email domain " +
"(or pass --email-from / PLATFORM_EMAIL_FROM; per environment: per_environment.<env>.from_email)"

// requireSender refuses plan and apply before anything changes when an
// environment's apps would get an unset or example FROM_EMAIL: they run
// with APP_ENV=production, which refuses to boot with one, so the deploy
// would end in a crash loop. apply on a terminal without --yes asks for one
// and writes it into platform.yaml.
func (setup *setup) requireSender() error {
problems := setup.cfg.SenderProblems()
if len(problems) == 0 {
return nil
}
if setup.apply && !setup.yes && stty(context.Background(), "-g") == nil {
fmt.Fprint(os.Stderr, "Sender address for auth emails, on your verified email domain (e.g. noreply@yourdomain.com): ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
sender := strings.TrimSpace(line)
if platform.RealSender(sender) {
changed, err := persist(setup.cfgPath, []*override{{key: "email.from", value: &sender}})
if err != nil {
return err
}
setup.say("platform.yaml: set %s (commit it)", strings.Join(changed, ", "))
if setup.cfg, err = platform.LoadResolved(setup.cfgPath); err != nil {
return err
}
if problems = setup.cfg.SenderProblems(); len(problems) == 0 {
return nil
}
}
}
setup.next("Set a real sender for auth emails: %s", senderFix)
return fmt.Errorf("refusing to deploy apps that would not boot: production needs a real FROM_EMAIL (%s): %s", strings.Join(problems, "; "), senderFix)
}

// confirm asks on a terminal; without one it needs --yes.
func (setup *setup) confirm(question string) error {
if setup.yes {
Expand Down
1 change: 1 addition & 0 deletions deploy/cmd/railway-setup/overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func overrides() []*override {
{key: "admin", flagName: "admin", env: "PLATFORM_ADMIN", usage: "true|false: the admin dashboard and its own Postgres"},
{key: "region", flagName: "region", env: "PLATFORM_REGION", usage: "Railway region for every service (\"\": Railway's default)"},
{key: "repo", flagName: "repo", env: "PLATFORM_REPO", usage: "GitHub owner/name, auto (origin remote) or \"\" (upload only)"},
{key: "email.from", flagName: "email-from", env: "PLATFORM_EMAIL_FROM", usage: "sender of auth emails, on your verified email domain (required: production refuses an example one)"},
}
}

Expand Down
6 changes: 5 additions & 1 deletion deploy/platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ secrets:
# config (prd_admin, ...) the web token cannot read.
project: go-fast-stack

# Required: the sender of every auth email (FROM_EMAIL), on a domain your
# mail provider (Resend) has verified. The apps run with APP_ENV=production,
# which refuses to boot without one or with an example address, so plan and
# apply refuse first. Or pass --email-from / PLATFORM_EMAIL_FROM.
email:
from: noreply@example.com
from: ""

# What one environment's apps see as their origin and sender. public_url
# (web PUBLIC_URL) and admin_public_url (ADMIN_PUBLIC_URL): railway for the
Expand Down
47 changes: 45 additions & 2 deletions deploy/platform/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,49 @@ func (config Config) Settings(env string) EnvSettings {
return settings
}

// reservedSenderDomains are the example and test domains (RFC 2606, RFC
// 6761) no mail provider sends from. The apps refuse a production
// FROM_EMAIL on them at boot (apps/*/internal/config realSender): keep the
// two lists the same.
var reservedSenderDomains = []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"}

// RealSender reports whether address ("a@b.c" or "Name <a@b.c>") is an email
// address off the reserved example and test domains: the rule the apps
// apply to FROM_EMAIL in production.
func RealSender(address string) bool {
address = strings.TrimSuffix(strings.TrimSpace(address), ">")
at := strings.LastIndex(address, "@")
if at <= 0 || at == len(address)-1 {
return false
}
domain := strings.TrimSuffix(strings.ToLower(address[at+1:]), ".")
for _, reserved := range reservedSenderDomains {
if domain == reserved || strings.HasSuffix(domain, "."+reserved) {
return false
}
}
return true
}

// SenderProblems lists the environments whose apps would get an unset or
// example FROM_EMAIL (email.from, or per_environment.<env>.from_email):
// every service runs with APP_ENV=production, which refuses to boot with
// one. A manual value is set by hand in Railway and is not checked here.
func (config Config) SenderProblems() []string {
var out []string
for _, env := range config.Environments {
from := config.Settings(env).FromEmail
switch {
case from == ManualValue:
case from == "":
out = append(out, env+": email.from is not set")
case !RealSender(from):
out = append(out, fmt.Sprintf("%s: %q is an example or test address", env, from))
}
}
return out
}

// HandSet lists env's variables IaC leaves alone because platform.yaml
// marks them manual, as "service/NAME". Plan and apply print them.
func (config Config) HandSet(env string) []string {
Expand Down Expand Up @@ -288,7 +331,7 @@ var (
Branch: "main",
Environments: Environments{"production"},
Secrets: Secrets{Provider: ProviderDoppler, Project: DefaultDopplerProject},
Email: Email{From: "noreply@example.com"},
Email: Email{}, // From is required: a sender on your verified domain (SenderProblems)
Backups: Backups{Enabled: true, Schedule: "0 3 * * *", KeepDaily: defaultKeepDaily, KeepWeekly: defaultKeepWeekly, Admin: true, Storage: StorageRailway},
Resources: Resources{
Web: Size{CPU: 1, MemoryMB: defaultWebMemoryMB, Replicas: 1},
Expand Down Expand Up @@ -385,7 +428,7 @@ func (config Config) Validate() error {
default:
add("secrets.provider must be %s or %s, got %q", ProviderDoppler, ProviderEnv, config.Secrets.Provider)
}
if !strings.Contains(config.Email.From, "@") {
if config.Email.From != "" && !strings.Contains(config.Email.From, "@") {
add("email.from must be an email address, got %q", config.Email.From)
}
for env, settings := range config.PerEnvironment {
Expand Down
29 changes: 25 additions & 4 deletions deploy/platform/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,26 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) {
var changed []string
for _, entry := range keys {
text := render(entry)
// A dotted name ("email.from") is a key of a top-level mapping.
mapping, name := root, entry.Name
if parent, child, nested := strings.Cut(entry.Name, "."); nested {
if mapping = childMapping(root, parent); mapping == nil {
return nil, nil, fmt.Errorf("platform.yaml: add %s:\n %s: %s to set it automatically", parent, child, text)
}
name = child
}
found := false
for index := 0; index+1 < len(root.Content); index += 2 {
key, val := root.Content[index], root.Content[index+1]
if key.Value != entry.Name {
for index := 0; index+1 < len(mapping.Content); index += 2 {
key, val := mapping.Content[index], mapping.Content[index+1]
if key.Value != name {
continue
}
found = true
if val.Kind == yaml.ScalarNode && val.Value == entry.Value {
break
}
if val.Line != key.Line || val.Line > len(lines) {
return nil, nil, fmt.Errorf("platform.yaml: put %s on one line (e.g. %s: %s) to set it automatically", entry.Name, entry.Name, text)
return nil, nil, fmt.Errorf("platform.yaml: put %s on one line (e.g. %s: %s) to set it automatically", entry.Name, name, text)
}
line := lines[val.Line-1]
start := val.Column - 1
Expand All @@ -55,6 +63,9 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) {
break
}
if !found {
if mapping != root {
return nil, nil, fmt.Errorf("platform.yaml: add %s: %s under %s to set it automatically", name, text, strings.TrimSuffix(entry.Name, "."+name))
}
if count := len(lines); count > 0 && !strings.HasSuffix(lines[count-1], "\n") {
lines[count-1] += "\n"
}
Expand All @@ -72,6 +83,16 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) {
return out, changed, nil
}

// childMapping is the mapping under a top-level key, or nil.
func childMapping(root *yaml.Node, name string) *yaml.Node {
for index := 0; index+1 < len(root.Content); index += 2 {
if root.Content[index].Value == name && root.Content[index+1].Kind == yaml.MappingNode {
return root.Content[index+1]
}
}
return nil
}

// render is a value as YAML text: booleans bare, strings plain when that
// reads back as the same string, else double-quoted.
func render(key Key) string {
Expand Down
2 changes: 2 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ make railway-doctor # login, workspace, SSH key, GitHub access, Doppler: print
make railway-setup # asks once before creating the project; ends with every /healthz checked
```

**A real sender is required.** Every service runs with `APP_ENV=production`, which refuses to boot when `FROM_EMAIL` is unset or on an example or test domain (`example.com`, `example.net`, `example.org`, `.example`, `.test`, `.invalid`, `localhost`). So `make railway-plan`, `make railway-setup` and `make railway-apply` refuse first and touch nothing until `email.from` in `deploy/platform.yaml` is a sender on a domain your mail provider has verified. Per environment, `per_environment.<env>.from_email` overrides it (`manual`: you set `FROM_EMAIL` in Railway yourself). Setup on a terminal asks for the sender and writes it into `platform.yaml`. Otherwise pass `--email-from noreply@yourdomain.com` or `PLATFORM_EMAIL_FROM`, which are written into the file the same way. `make railway-doctor` reports it as a failed check.

The defaults come from the checkout: the project is named after your repo, and production is the only environment. Choose on the first run: `make railway-setup ARGS="--environments staging,production --admin true --workspace acme"`. These flags (and `--project`, `--region`, `--repo`; or `PLATFORM_*` env vars) are written into `platform.yaml`, so commit it afterwards.

`make railway-plan` shows what would change without touching anything (exit 2 when something would). `make railway-verify` checks `/healthz` on every public URL. `make railway-rotate-db` gives every Postgres a new superuser password (see below). Agents: see AGENTS.md "Deploy to Railway (for agents)" for `--yes`, `--json` and exit codes.
Expand Down
Loading