Skip to content

Environment Variables and dotenv

Environment variables are how you pass configuration to a running process without hardcoding values in source code. They’re the standard way to handle database URLs, API keys, JWT secrets, and port numbers across different environments (local, staging, production).

Imagine hardcoding your JWT secret:

// ❌ Never do this
const JWT_SECRET = 'my-super-secret-key-12345'

This gets committed to git, pushed to GitHub, and anyone who sees the repo can forge JWTs for your API. Environment variables keep secrets out of source code.

Node.js exposes environment variables via process.env:

const port = process.env.PORT // '3000' (string)
const secret = process.env.JWT_SECRET // 'abc...' or undefined
const env = process.env.NODE_ENV // 'development' | 'production' | undefined

All values are strings. Parse numbers explicitly:

const port = parseInt(process.env.PORT || '3000', 10)

In production (Railway, Vercel, etc.), environment variables are set in the hosting platform’s dashboard. Locally, you need another way to set them without typing them on every command.

dotenv loads a .env file into process.env:

Terminal window
npm install dotenv

Create .env in the project root:

PORT=3000
JWT_SECRET=change-this-to-a-long-random-string-in-production
DATABASE_PATH=./bulletin.db
NODE_ENV=development

Load it at the very start of your application:

// src/index.ts — first line before any other imports that use process.env
import 'dotenv/config'
// Now process.env.PORT, process.env.JWT_SECRET, etc. are available

Never commit .env to git. It contains secrets. Add it to .gitignore:

node_modules/
dist/
.env
*.db

Commit a .env.example file that lists required variables without their values:

PORT=
JWT_SECRET=
DATABASE_PATH=
NODE_ENV=

This documents what variables the application needs without exposing actual values. New developers copy this to .env and fill in their own values.

Don’t let the server start with missing configuration:

src/config.ts
import 'dotenv/config'
function required(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`Environment variable ${name} is required`)
return value
}
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
jwtSecret: required('JWT_SECRET'),
dbPath: process.env.DATABASE_PATH || './bulletin.db',
nodeEnv: process.env.NODE_ENV || 'development',
}

Import config instead of accessing process.env directly throughout the codebase. This centralizes validation and provides TypeScript types.

When you deploy the Bulletin API to Railway:

  1. Go to your Railway project → Variables
  2. Add each variable: JWT_SECRET, PORT, NODE_ENV=production
  3. Railway injects them into process.env at runtime — no .env file needed
  1. Create a .env file in your project with PORT=3000, JWT_SECRET=dev-secret-change-in-prod, and NODE_ENV=development.
  2. Create src/config.ts with the validation pattern above.
  3. Import config in src/index.ts and log config.port.
  4. Delete JWT_SECRET from .env and observe the error on startup.
  5. Create .env.example listing all required variables without values.
  • Environment variables keep secrets and config out of source code.
  • process.env.NAME reads a variable — value is always a string.
  • dotenv (and import 'dotenv/config') loads .env into process.env for local development.
  • Never commit .env; always commit .env.example.
  • Centralize env validation in a config.ts file for clean, typed access across the codebase.