Environment, Config & Deployment
Advanced · 20 min read · ▶ live playground · ✦ checkpoint
The last lesson introduced DATABASE_URL, the string your app uses to reach its database. That one value brings the whole deployment problem into view: different environments need different settings, and some settings are secrets.
Server code is not finished when it runs on your laptop. It needs a reliable way to read configuration, refuse to start when required values are missing, and run somewhere other people can reach it.
Secrets never live in code or Git
A secret is any value that grants access: database passwords, API keys, session-signing secrets, tokens, private keys. If leaking it would force you to rotate a credential, treat it as a secret.
Do not put secrets directly in source files:
export const databaseUrl = "postgres://app:real-password@example.com/app";And do not commit a local .env file. A local .env is a private convenience file that sets environment variables while you develop.
DATABASE_URL=postgres://course:local-password@localhost:5432/course_dev
SESSION_SECRET=local-dev-long-random-string
NODE_ENV=development
PORT=3000Your repository should ignore the real .env file and may commit an example file that lists names without real values:
.env
.env.*
!.env.exampleDATABASE_URL=
SESSION_SECRET=
NODE_ENV=development
PORT=3000For a simple bash-style local run, you can export the variables before starting Node:
set -a
source .env
set +a
node api-server.mjsMany project tools can load .env files for local development too. The durable rule is not the exact loader. The durable rule is: local secrets stay local, and deployed apps get their environment variables from the deployment provider's settings, dashboard, CLI, or secret manager. Your .env file does not travel to production unless you deliberately copy those values into the deployment environment, which you should not do with Git.
Validate config at startup
Environment variables arrive as strings or missing values. Treat them like request bodies: validate at the boundary, then let the rest of the app use a clean config object.
function readConfig(env) {
const required = ["DATABASE_URL", "SESSION_SECRET"];
const missing = required.filter((name) => !env[name]);
if (missing.length > 0) {
throw new Error(`Missing required environment variable: ${missing.join(", ")}`);
}
const port = Number(env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`PORT must be a number from 1 to 65535`);
}
return {
databaseUrl: env.DATABASE_URL,
sessionSecret: env.SESSION_SECRET,
nodeEnv: env.NODE_ENV ?? "development",
port,
};
}
const examples = [
{ DATABASE_URL: "postgres://local", PORT: "3000" },
{
DATABASE_URL: "postgres://prod",
SESSION_SECRET: "long-random-value",
NODE_ENV: "production",
PORT: "8080",
},
];
for (const env of examples) {
try {
const config = readConfig(env);
console.log(`${config.nodeEnv}:${config.port}`);
} catch (error) {
console.log(error.message);
}
}→ Missing required environment variable: SESSION_SECRET, then production:8080.
A real app usually reads from process.env once during startup:
export function readConfig(env) {
const required = ["DATABASE_URL", "SESSION_SECRET"];
const missing = required.filter((name) => !env[name]);
if (missing.length > 0) {
throw new Error(`Missing required environment variable: ${missing.join(", ")}`);
}
const port = Number(env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT must be a number from 1 to 65535");
}
return {
databaseUrl: env.DATABASE_URL,
sessionSecret: env.SESSION_SECRET,
nodeEnv: env.NODE_ENV ?? "development",
port,
};
}
export const config = readConfig(process.env);Then the server imports the already-validated config:
import { createServer } from "node:http";
import { config } from "./config.mjs";
const server = createServer((req, res) => {
res.end("ok");
});
server.listen(config.port, () => {
console.log(`Listening on port ${config.port}`);
});This pattern is boring on purpose. If DATABASE_URL is missing, the app fails at startup. If PORT is invalid, the app fails at startup. You find configuration problems before a user hits the broken path.
Configuration changes per environment
An environment is a place your app runs: local development, test, staging, production, preview, CI. The code should mostly stay the same. The configuration changes.
Common examples:
- Local development uses a local or disposable database.
- Tests use isolated test data.
- Staging uses production-like settings without touching real users.
- Production uses real credentials, real domains, stricter logging, and managed secrets.
Avoid spreading checks like if (process.env.NODE_ENV === "production") across the codebase. Read and validate config once, then pass the config object to the parts of the app that need it. That keeps "what environment am I in?" near the edge instead of smeared through every route and database function.
Also separate secret config from public config. A server can read DATABASE_URL. Browser code must not receive it. Anything bundled into client-side JavaScript should be treated as public, even if its name contains "key."
The deployment map
Deployment means running your server somewhere other than your laptop. The beginner map has three common shapes.
A long-running server or VM is closest to what you built in lesson 22.3: start a Node process, keep it alive, listen on a port, restart it when it crashes or when you deploy a new version. This shape is easy to reason about for APIs, background jobs, WebSockets, queues, scheduled work, and apps that benefit from one always-on process. You are responsible for more operational details: process management, logs, updates, scaling, and health checks.
Serverless functions package each route or handler as a function the platform runs when requests arrive. This can be a good fit for request-response APIs with clear boundaries and uneven traffic. The tradeoff is that your code should not rely on in-memory state, startup can happen more often, long-running work may not fit, and provider limits matter. Environment variables still come from the platform, not from your local .env.
Containers package your app plus its runtime assumptions into an image. A container can run on many platforms with the same start command, which helps when you need repeatable environments, system packages, workers, or several services. The tradeoff is extra build and operations knowledge: images, registries, ports, health checks, and orchestration.
If you are choosing for a first deployed Node API, start with the shape your platform makes simplest. Pick a long-running server when your app truly needs an always-on process. Pick functions when each request can stand alone. Pick containers when repeatability and operational control matter more than beginner simplicity.
In every shape, the app needs the same basics:
- A start command such as
node src/server.mjs. - Environment variables set in the deployment environment.
- Logs you can read after a failure.
- A health path or simple request you can test.
- A database connection string that belongs to that environment.
Section 22 moved JavaScript onto the server: runtime APIs, files and processes, HTTP APIs, databases, and deployment configuration. Next section shifts from one-person code to team code: Git history, branches, pull requests, and the collaboration habits that keep changes understandable.
Checkpoint
Answer all three to mark this lesson complete