Environment Variables on Render
Render's model differs from Vercel's in one important way: backend variables are read at runtime, not baked into a build.
1. Setting Them
Dashboard
Service → Environment → Add Environment Variable. Changing one triggers a restart, not a rebuild.
In render.yaml
services:
- type: web
name: api
envVars:
- key: NODE_ENV
value: production
- key: DATABASE_URL
fromDatabase:
name: app-db
property: connectionString
- key: REDIS_URL
fromService:
type: keyvalue
name: app-cache
property: connectionString
- key: JWT_SECRET
generateValue: true # Render generates a strong random value once
- key: STRIPE_SECRET_KEY
sync: false # declared here, value set in the dashboard
- fromGroup: shared-config # pull in an environment group2. The Four Value Sources
| Source | What it does |
|---|---|
value | A literal — only for non-secrets |
fromDatabase | Injects a managed database's connection string |
fromService | Injects another service's URL or connection string |
generateValue: true | Render generates a random secret once, on first deploy |
sync: false | Declares the key in git; the value is set manually in the dashboard |
Why These Matter
fromDatabase / fromService — credentials never appear in your repository, and they automatically use the internal connection string, which is faster, free of egress cost, and not exposed to the public internet.
generateValue: true — you never invent a JWT_SECRET yourself, and it is never committed. Render produces a strong random value and injects it.
sync: false — the best pattern for third-party secrets. The key is visible in version control so nobody forgets it exists and a new environment fails loudly if it's unset, but the value is never in git.
Naming sync: false in an interview is a small detail that signals real experience with the platform.
3. Environment Groups
Share variables across services. Your API, worker and cron job all need the same DATABASE_URL and REDIS_URL.
envVarGroups:
- name: shared-config
envVars:
- key: LOG_LEVEL
value: info
- key: NODE_ENV
value: production
services:
- type: web
name: api
envVars:
- fromGroup: shared-config
- type: worker
name: worker
envVars:
- fromGroup: shared-configOne definition, one place to update. Without groups, changing LOG_LEVEL means editing three services and forgetting one.
4. Runtime vs Build-Time
This is the key difference from Vercel.
| Backend (Web Service, Worker) | Frontend (Static Site) | |
|---|---|---|
| Read at | Runtime — process.env.X when the code runs | Build time — substituted into the bundle |
| Change takes effect | On restart | Only after a rebuild |
| Can hold secrets | Yes | Never |
| Visible to users | No | Yes, in the bundle |
// Web Service — read at runtime, safe for secrets
const secret = process.env.JWT_SECRET;// Static Site — substituted at build, public forever
const apiUrl = import.meta.env.VITE_API_URL;The Practical Consequence
On a Web Service you can rotate a secret and restart — no rebuild, seconds of downtime at most.
On a Static Site, changing VITE_API_URL needs a full rebuild, because the old value is already inside the shipped JavaScript.
5. Internal vs External Connection Strings
Render gives every managed database and private service two URLs.
| Internal | External | |
|---|---|---|
| Reachable from | Other Render services in the same region | Anywhere |
| Path | Private network | Public internet |
| Latency | Low | Higher |
| Egress cost | Free | Charged |
| Exposure | Not publicly reachable | Publicly reachable |
Always use the internal URL for service-to-service traffic. fromDatabase does this automatically — another reason to use it instead of pasting a string.
Use the external URL only from your laptop, or from a migration script running outside Render. Rotate the external credentials if you suspect they leaked.
6. Secret Files
For credentials that are files rather than strings — a service account JSON, a certificate, a private key.
Dashboard → Environment → Secret Files. Mounted into the container at a path you choose.
const credentials = JSON.parse(
fs.readFileSync("/etc/secrets/gcp-service-account.json", "utf8")
);Better than stuffing a multi-line JSON blob into an environment variable, where newline escaping causes constant grief.
7. Validate at Startup
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
});
export const env = envSchema.parse(process.env);import { env } from "./env";
app.listen(env.PORT, "0.0.0.0"); // number, not stringWhy It Matters More Here
Render restarts the service when a variable changes. If a typo makes DATABASE_URL undefined, you want the process to refuse to start — Render's health check then fails, the old instance keeps serving, and the bad deploy never takes traffic.
Without validation, the service boots "fine" and fails on the first request that touches the database. Zod turns a silent production outage into a failed deploy.
"Fail fast at startup" — and on Render, failing fast means the deploy is safely rejected.
Note on PORT
PORT is injected by Render. Read it, don't hardcode it, and bind to 0.0.0.0:
app.listen(process.env.PORT || 3000, "0.0.0.0");Binding to 127.0.0.1 is the most common reason a deploy "succeeds" but the service is unreachable.
8. Local Development
# .env.local — gitignored
DATABASE_URL="postgres://localhost:5432/dev"
JWT_SECRET="local-dev-secret-at-least-32-characters"
REDIS_URL="redis://localhost:6379"# .gitignore
.env
.env.*
!.env.exampleCommit a .env.example with every key and dummy values, so a new developer knows exactly what to set and the validation schema tells them if they missed one.
Never Point Local Development at Production
Use a local Postgres in Docker, or a dedicated development database. Running a migration or a seed script against production because your .env had the wrong URL is a real and very bad afternoon.
# docker-compose.yml for local dependencies
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
ports: ["5432:5432"]
redis:
image: redis:7
ports: ["6379:6379"]9. Rotating a Secret
- Generate the new value at the provider
- Update it in Render (dashboard or environment group)
- Render restarts the service with the new value
- Verify the service is healthy
- Revoke the old credential at the provider
For a zero-downtime rotation of something like a JWT signing key, accept both keys during a transition window, then drop the old one — otherwise every existing session is invalidated at once.
If a Secret Reached Git
Rotate first, clean history second.
- Generate and deploy a new secret
- Revoke the old one
- Then purge history with
git filter-repoor BFG and force push - Assume it was scraped — public repos are scanned within minutes
Deleting the line in a follow-up commit changes nothing: the value remains in history, in every clone, and in every fork.
10. Checklist
- [ ]
DATABASE_URLandREDIS_URLinjected viafromDatabase/fromService, not pasted - [ ] Internal connection strings used for service-to-service traffic
- [ ]
JWT_SECRETcreated withgenerateValue: true - [ ] Third-party secrets declared with
sync: false - [ ] Shared variables in an environment group
- [ ] Validated at startup with Zod
- [ ]
PORTread from the environment; bound to0.0.0.0 - [ ] Separate values per environment — staging never shares a secret with production
- [ ]
.env*gitignored;.env.examplecommitted - [ ] No secret in a
VITE_/NEXT_PUBLIC_variable on a static site - [ ] Secrets redacted in the logging configuration
- [ ] Rotation plan for suspected leaks and staff departures