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: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ AUTH_SECRET=dev-only-secret-change-me-0123456789abcdef
# Required in production. Without it auth emails are logged, not sent.
# With SECRETS_PROVIDER=doppler it comes from Doppler instead (this is ignored).
RESEND_API_KEY=
# Required in production: a sender on your Resend-verified domain (an
# example.com address is refused there).
FROM_EMAIL=noreply@example.com

# Google / Apple sign-in (optional, docs/sign-in.md). A
Expand Down
29 changes: 29 additions & 0 deletions apps/admin/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ func Load(getenv func(string) string) (Config, error) {
default:
errs = append(errs, fmt.Errorf("APP_ENV must be development, test or production, got %q", cfg.Env))
}
// Unset, FROM_EMAIL falls back to the example sender, which no mail
// provider will send from: every invite would fail, only in the log.
if cfg.Env == Production {
switch {
case get("FROM_EMAIL", "") == "":
errs = append(errs, errors.New("FROM_EMAIL is required in production (a sender on your verified email domain)"))
case !realSender(cfg.FromEmail):
errs = append(errs, fmt.Errorf("FROM_EMAIL must be a sender on your verified email domain in production, got %q", cfg.FromEmail))
}
}

// ADMIN_PORT first: locally .env sets PORT for the web app. Railway sets
// PORT per service.
Expand Down Expand Up @@ -119,6 +129,25 @@ func Load(getenv func(string) string) (Config, error) {
return cfg, nil
}

// realSender reports whether addr ("a@b.c" or "Name <a@b.c>") is an email
// address off the reserved example and test domains (RFC 2606, RFC 6761).
// deploy/platform's reservedSenderDomains refuses the same domains before a
// deploy: keep the two lists the same.
func realSender(addr string) bool {
addr = strings.TrimSuffix(strings.TrimSpace(addr), ">")
at := strings.LastIndex(addr, "@")
if at <= 0 || at == len(addr)-1 {
return false
}
domain := strings.TrimSuffix(strings.ToLower(addr[at+1:]), ".")
for _, reserved := range []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"} {
if domain == reserved || strings.HasSuffix(domain, "."+reserved) {
return false
}
}
return true
}

func parseOrigin(key, raw string) (*url.URL, error) {
if raw == "" {
return nil, fmt.Errorf("%s is required (e.g. http://<host>:3001)", key)
Expand Down
29 changes: 29 additions & 0 deletions apps/web/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ func Load(getenv func(string) string) (Config, error) {
default:
errs = append(errs, fmt.Errorf("APP_ENV must be development, test or production, got %q", cfg.Env))
}
// Unset, FROM_EMAIL falls back to the example sender, which no mail
// provider will send from: every auth email would fail, only in the log.
if cfg.Env == Production {
switch {
case get("FROM_EMAIL", "") == "":
errs = append(errs, errors.New("FROM_EMAIL is required in production (a sender on your verified email domain)"))
case !realSender(cfg.FromEmail):
errs = append(errs, fmt.Errorf("FROM_EMAIL must be a sender on your verified email domain in production, got %q", cfg.FromEmail))
}
}

port := get("PORT", "3000")
cfg.Addr = net.JoinHostPort("0.0.0.0", port)
Expand Down Expand Up @@ -103,6 +113,25 @@ func Load(getenv func(string) string) (Config, error) {
return cfg, nil
}

// realSender reports whether addr ("a@b.c" or "Name <a@b.c>") is an email
// address off the reserved example and test domains (RFC 2606, RFC 6761).
// deploy/platform's reservedSenderDomains refuses the same domains before a
// deploy: keep the two lists the same.
func realSender(addr string) bool {
addr = strings.TrimSuffix(strings.TrimSpace(addr), ">")
at := strings.LastIndex(addr, "@")
if at <= 0 || at == len(addr)-1 {
return false
}
domain := strings.TrimSuffix(strings.ToLower(addr[at+1:]), ".")
for _, reserved := range []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"} {
if domain == reserved || strings.HasSuffix(domain, "."+reserved) {
return false
}
}
return true
}

func parseOrigin(key, raw string) (*url.URL, error) {
if raw == "" {
return nil, fmt.Errorf("%s is required (e.g. http://<host>:3000)", key)
Expand Down
2 changes: 1 addition & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,4 @@ Off by default. `previews: true` (needs `staging`) turns on Railway PR environme

`/healthz` is only the deploy gate: Railway checks it until a new deployment goes live, never afterwards. It answers 503 when Doppler secrets go stale (`SECRETS_MAX_STALENESS`), so never wire it up as a liveness or restart probe, or a Doppler outage turns into a restart loop. Services restart only when the process exits (`ON_FAILURE`, 5 retries).

Every service runs with `APP_ENV=production` (email verification on, `RESEND_API_KEY` required), `CLIENT_IP_HEADER=X-Real-IP` and `PORT=3000`. The server refuses to boot with a missing or invalid value and lists every problem.
Every service runs with `APP_ENV=production` (email verification on, `RESEND_API_KEY` required, and a real `FROM_EMAIL`: see "A real sender is required" above), `CLIENT_IP_HEADER=X-Real-IP` and `PORT=3000`. The server refuses to boot with a missing or invalid value and lists every problem.
12 changes: 8 additions & 4 deletions scripts/release-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
# exists), with the binaries' own `migrate`, as pre-deploy does.
# 3. boot both with APP_ENV=production and only the config production
# requires (no Doppler, no optional integrations; a dummy
# RESEND_API_KEY, which production requires) and wait for
# /healthz 200.
# RESEND_API_KEY and a FROM_EMAIL off the example domains, which
# production requires; nothing is sent) and wait for /healthz 200.
#
# Env: SMOKE_DATABASE_URL, SMOKE_ADMIN_DATABASE_URL empty databases to use
# (`make release-smoke` and CI create them); SMOKE_BOOT_SECONDS,
Expand All @@ -25,6 +25,9 @@ readonly default_admin_port=3391
readonly poll_seconds=0.2 # between /healthz attempts
readonly http_ok=200
readonly log_tail_lines=40 # of a failed app's log
# Production refuses an unset or example.com sender; nothing is ever sent
# (the Resend key is a dummy), so any unreserved domain will do.
readonly smoke_from_email=release-smoke@go-fast-stack.dev

app_db=${SMOKE_DATABASE_URL:?SMOKE_DATABASE_URL: an empty database for the web app}
admin_db=${SMOKE_ADMIN_DATABASE_URL:-}
Expand Down Expand Up @@ -75,10 +78,11 @@ done
admin=false
[ -x "$work/admin" ] && [ -n "$admin_db" ] && admin=true
web_env=(APP_ENV=production PORT="$web_port" PUBLIC_URL="http://127.0.0.1:$web_port"
DATABASE_URL="$app_db" AUTH_SECRET=release-smoke-web-auth-secret-0123456789 RESEND_API_KEY=re_release_smoke_unused)
DATABASE_URL="$app_db" AUTH_SECRET=release-smoke-web-auth-secret-0123456789 RESEND_API_KEY=re_release_smoke_unused
FROM_EMAIL="$smoke_from_email")
admin_env=(APP_ENV=production ADMIN_PORT="$admin_port" ADMIN_PUBLIC_URL="http://127.0.0.1:$admin_port"
DATABASE_URL="$app_db" ADMIN_DATABASE_URL="$admin_db" ADMIN_AUTH_SECRET=release-smoke-admin-auth-secret-0123456789
RESEND_API_KEY=re_release_smoke_unused)
RESEND_API_KEY=re_release_smoke_unused FROM_EMAIL="$smoke_from_email")
run() { env -i PATH="$PATH" HOME="$HOME" "$@"; }
step migrate
run "${web_env[@]}" "$work/server" migrate >"$work/web-migrate.log" 2>&1 ||
Expand Down
Loading