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.
.envis ignored by Git;.env.example, with the keys and dummy values, is committed.
# .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
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.
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 setisAdmin. Pick the fields you accept. - Limit body size (
express.json({ limit: "100kb" })) and the length of every string.
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.
// 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
// 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,sameSitecookies. - Keep dependencies current;
npm auditin 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.