RizTech Academy logo
RizTech Academy
Shipping ItLesson 1 of 525 min

Environments, secrets and configuration

Everything that makes your machine different from a server lives in configuration, and almost every "works on my machine" story ends there.

The fallback is the bug

const secret = process.env.JWT_SECRET ?? "dev-secret";
origin: process.env.CORS_ORIGIN ?? "http://localhost:3000",

Both of those look careful. Both are how a shop ends up misconfigured in production with nothing in the logs.

process.env.X is string | undefined, so every read invites a fallback, and the fallback is always chosen to make local development pleasant. The pleasant option is the dangerous one, because it is the one that lets the process start.

A default JWT secret is every account at once — anybody who reads your source can mint a token for any user. A permissive CORS origin is an API readable by any site on the internet. Neither crashes. Neither logs anything. The system works normally and is quietly wrong, which is the worst failure mode there is.

One schema, checked before anything starts

const schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3001),

  DATABASE_URL: z.string().url().startsWith("postgres"),

  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default("15m"),

  PAYMENT_WEBHOOK_SECRET: z.string().min(16),
  PAYMENT_PUBLIC_KEY: z.string().default("kirana_test_key"),

  CORS_ORIGIN: z.string().url(),

  LOG_LEVEL: z.enum(["error", "warn", "log", "debug", "verbose"]).default("log"),
});
ConfigModule.forRoot({ isGlobal: true, cache: true, validate: validateEnv })

validate runs during bootstrap, before a single provider is constructed. A missing variable is a startup failure, not a 500 on whichever route happens to need it first.

Which of these have defaults is the whole design:

Variable Default? Why
PORT, LOG_LEVEL yes being wrong is inconvenient
JWT_SECRET, PAYMENT_WEBHOOK_SECRET no being wrong is a breach
CORS_ORIGIN no wrong means a broken shop or an open one
DATABASE_URL no there is no sensible guess

.min(32) on the JWT secret is not decoration. A short secret is brute-forceable offline, and a forged token is every account.

Report everything at once

const problems = result.error.issues
  .map((issue) => `  ${issue.path.join(".")}: ${issue.message}`)
  .join("\n");

throw new Error(`Environment is not usable:\n${problems}\n`);

Reporting one problem at a time means as many failed deployments as there are missing variables. Try it with three things wrong:

Environment is not usable:
  DATABASE_URL: Invalid input: must start with "postgres"
  JWT_SECRET: String must contain at least 32 character(s)
  CORS_ORIGIN: Required

Every one of those names the variable and what was wrong with it. That is the difference between a two-minute fix and an afternoon.

And it never logs the values. This is the one place in the codebase that has all of them, which makes it the one place most likely to leak them.

A failed start is a good outcome

Worth saying plainly, because it feels wrong.

A deployment that will not start is a rollback: the platform sees the new containers failing their health check, keeps the old ones, and nothing happens to customers. Somebody reads the message, adds the variable, deploys again.

A deployment that starts with no authentication is an incident.

Given the choice between those two, the process should refuse to start. Every time.

Where secrets actually live

Never in the repository. .env is gitignored and .env.example is committed — the names, with no values:

DATABASE_URL=postgresql://kirana:localdev@localhost:5432/kirana
JWT_SECRET=generate-with-openssl-rand-base64-32
PAYMENT_WEBHOOK_SECRET=generate-with-openssl-rand-base64-32

The placeholder tells you how to make a good one. A developer who reads your-secret-here types your-secret-here.

In production, secrets come from the platform: Vercel's environment variables, Railway or Render's settings, AWS Secrets Manager, Kubernetes secrets. All the same idea — set outside the code, injected as environment variables, visible to the people who need them and nobody else.

If a secret has ever been committed, it is compromised. Rotate it. Deleting the commit does not help: it is in every clone and in the reflog, and you do not know who pulled.

NEXT_PUBLIC_ is a publishing decision

The rule that catches everybody:

ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN npm run build --workspace=apps/web

Anything prefixed NEXT_PUBLIC_ is baked into the JavaScript bundle at build time. Two consequences, and both surprise people:

It cannot be changed at runtime. Setting it on a running container does nothing; the value is already in the file the browser downloaded. Changing it means rebuilding.

It is published. Every visitor has it, in plain text, in a file they can read. A secret with that prefix is a secret on the internet.

So the shop has two variables for the same API:

API_URL=http://localhost:3001/api            # server-side, not in the bundle
NEXT_PUBLIC_API_URL=http://localhost:3001/api # in the bundle, public

API_URL is what the Next server uses. On a platform with private networking those differ — the server reaches the API over an internal address the browser could never resolve.

In this shop the browser never calls the API directly at all, because every mutation is a server action. NEXT_PUBLIC_API_URL is there for the day something does.

The environments themselves

Three, and each is a different question:

Development — your machine. Real PostgreSQL in Docker rather than SQLite, because "works locally, fails in production" is usually a difference in the database. Test keys for the payment gateway.

Preview — one per pull request, which Vercel does automatically. Its own database, seeded. This is where somebody who is not you clicks through the change before it is merged, and it is worth far more than a screenshot in a PR.

Production — real customers, real money, real data. The one where you never run the seed, which is why the seed refuses:

if (process.env.NODE_ENV === "production") {
  throw new Error("Refusing to seed: this deletes data and NODE_ENV is production.");
}

That guard exists because somebody, eventually, runs npm run db:seed against the wrong DATABASE_URL. It has happened to people far more careful than you.

Check your work

Why a fallback is a bug: it is chosen for local convenience and it is the option that lets a misconfigured process start.

Why validation runs at bootstrap: a missing variable becomes a failed deployment rather than a 500 on one route at three in the morning.

Which variables get defaults: the ones where being wrong is inconvenient. Nothing where being wrong is a breach.

Why report every problem at once: otherwise a deployment fails once per missing variable.

Why the validator never logs values: it is the one place holding all of them.

Why a failed start is good: the platform keeps the old containers, so it is a rollback rather than an incident.

Why .env.example is committed: the names and a placeholder that tells you how to generate a real value.

Why a committed secret is compromised: it is in every clone and the reflog, and deleting the commit does not unpublish it.

What NEXT_PUBLIC_ really means: baked in at build time, unchangeable at runtime, and published to every visitor.

Why the seed refuses in production: somebody eventually runs it against the wrong DATABASE_URL.

Practice

  1. Set JWT_SECRET to short and start the API. Read the message.
  2. Remove CORS_ORIGIN entirely and start it. Confirm it names that one too.
  3. Break three variables at once and confirm all three are reported.
  4. Add a fallback back into main.ts for CORS_ORIGIN, remove the variable, and start the API. Note that it starts, and work out what it is now allowing.
  5. Add a new variable to the schema without a default and deploy in your head: what happens to the running service?
  6. Search the codebase for process.env. Decide for each whether it should go through the schema.
  7. Put a fake secret in NEXT_PUBLIC_SECRET, build the web app, and grep .next for it.
  8. Try to run npm run db:seed with NODE_ENV=production.
  9. Write the .env.example you would hand a new developer, with a placeholder for each value that tells them how to generate it.
  10. Work out where each secret would live if this shop deployed to Vercel and Railway, and who in a three-person team could read each one.

Next: deploying the front end.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship