Configuration and safety

Configure a server without putting secrets in the repository, and refuse input that is not what it claims.

12of 18
≈ 12Min read
7Parts

Configuration comes from the environment

  • One artefact, configured per environment. No if (production) branches deciding which database to use.
  • Secrets in the environment or a secret manager, never in the code, never in the image.
  • .env is ignored by Git; .env.example, with the keys and dummy values, is committed.
the same build, every environment
# .env — on your machine only, and in .gitignore
DATABASE_URL=postgres://localhost:5432/doors
JWT_SECRET=a-long-random-string
PORT=3000

# node --env-file=.env app.js   (Node 20.6+, no package needed)

Validate it at startup

fail immediately, not at 3 a.m.
function loadConfig(env) {
  const missing = ["DATABASE_URL", "JWT_SECRET"].filter((k) => !env[k]);
  if (missing.length > 0) {
    throw new Error("Missing environment variables: " + missing.join(", "));
  }

  return {
    databaseUrl: env.DATABASE_URL,
    jwtSecret: env.JWT_SECRET,
    port: Number(env.PORT ?? 3000),
    isProduction: env.NODE_ENV === "production",
  };
}
A missing variable is undefined, not an error

Without a check, jwt.sign(payload, undefined) throws on the first login — in production, hours after the deploy, in a place that has nothing to do with configuration.

What never goes in Git

  • .env, private keys, certificates, service-account JSON, node_modules, database dumps.
  • A secret that was committed once is compromised — the history keeps it. Rotate it; do not just delete the file.
  • Add a scanner (gitleaks, GitHub secret scanning) so it is caught at the commit, not in a breach report.
.gitignore, from the first commit
node_modules/
.env
.env.*
!.env.example
*.pem
dist/

All input is hostile

  • Validate at the edge, once, and pass the parsed value on.
  • Never spread a request body straight into a database row: { ...req.body } lets a client set isAdmin. Pick the fields you accept.
  • Limit body size (express.json({ limit: "100kb" })) and the length of every string.
validate the shape, not just the presence
import { z } from "zod";

const Door = z.object({
  name: z.string().min(2).max(120),
  days: z.number().int().min(0).max(365),
  finish: z.enum(["painted", "veneer", "ceramic"]),
});

const result = Door.safeParse(req.body);
if (!result.success) return res.status(422).json({ error: "Validation failed", details: result.error.issues });

CORS, precisely

Access-Control-Allow-Origin: * with credentials is not even permitted by browsers — and when it is, it means any site can read your API as your user. Name the origins.

the check people get wrong
// looks fine, and allows evil-domvybe.com
if (origin.endsWith("domvybe.com")) allow();

// a list you wrote, compared exactly
const ALLOWED = ["https://domvybe.com", "https://www.domvybe.com"];
if (ALLOWED.includes(origin)) allow();

Paths and commands

two injections that are not SQL
// path traversal: ?name=../../etc/passwd
const full = path.resolve(UPLOADS, name);
if (!full.startsWith(UPLOADS + path.sep)) throw new Error("Nope");

// command injection: never build a shell string from input
exec(`convert ${file} out.png`);                    // no
execFile("convert", [file, "out.png"]);              // arguments, not a shell

A short, honest checklist

  • helmet() for security headers; HTTPS everywhere; HSTS.
  • Rate-limit authentication and anything expensive.
  • Hash passwords with bcrypt or argon2; sessions in httpOnly, sameSite cookies.
  • Keep dependencies current; npm audit in CI; read what a new dependency pulls in.
  • Log what happened, never what it contained — no passwords, tokens, card numbers or full request bodies.
  • Return generic error messages to clients, detailed ones to your logs.

Practice