The short answer#
Environment variables are configuration values that live outside your code and are handed to your app by the operating system when the process starts. They let the same codebase run locally, in staging and in production without changing a line: only the values change.
DATABASE_URL is the most widely used convention for holding your database connection string in a single variable. The golden rule: never write credentials in code and never commit your .env file. Declare variables in each platform's environment and read them with process.env in Node or os.environ in Python.
This guide covers how your app reads those variables, what each part of DATABASE_URL means, how to connect with verified TLS in production without breaking local development, where secrets belong, and what to do when one leaks.
Documentation: The Twelve-Factor App · III. Config ↗
The underlying principle: config doesn't live in code#
Before touching DATABASE_URL, it helps to understand why environment variables exist. The idea, popularized by the Twelve-Factor App methodology, is to separate two things that tend to get mixed up:
- Code: what your app does. It is identical on your laptop, in staging and in production.
- Config: the values that change between environments, such as the database URL, API keys or the run mode.
Put config inside the code and you have to edit and redeploy every time a value changes, and production credentials end up in files anyone with repo access can read. Environment variables fix both: the code asks for the database URL and the environment answers with the right value for wherever it's running.
A useful test: if you could open-source your repository today without exposing a single credential, your config is properly separated.
Documentation: The Twelve-Factor App · III. Config ↗
How your app reads an environment variable#
The operating system exposes variables to your app's process, and each language has its own accessor: the process.env object in Node.js and the os.environ mapping in Python.
// Node.js
const dbUrl = process.env.DATABASE_URL;
const port = Number(process.env.PORT) || 3000;
# Python
import os
db_url = os.environ.get("DATABASE_URL") # None if missing
port = int(os.environ.get("PORT", "3000"))
secret = os.environ["API_KEY"] # KeyError if missing: fail fastNote the default-value pattern for PORT: read the value from the environment and fall back to a fixed one if it's missing. This matters because many platforms assign the port dynamically through PORT, and your app must listen there rather than on a hardcoded port.
For required values such as DATABASE_URL or an API key, a default is a bad idea. It's better for the app to refuse to start with a clear message than to boot half-configured and fail on the first query. In Python, os.environ with square brackets raises KeyError when the variable is missing; in Node, check it yourself at startup.
For local development, recent Node versions can load a .env file with the --env-file flag, and python-dotenv is the usual choice in Python. In production you don't need that file: the platform injects variables directly.
Documentation: Node.js · process.env ↗ · Python · os.environ ↗
Anatomy of DATABASE_URL#
DATABASE_URL packs every connection detail into one URI-formatted string. Taking it apart is the fastest way to understand it:
postgresql://app_user:s3cr%[email protected]:5432/app_db?sslmode=verify-full
└─ scheme ─┘ └ user ┘ └password ┘ └──── host ────┘ └port ┘ └─ db ─┘ └─ params ─┘- Scheme: the database engine, such as postgresql://, mysql:// or mongodb://.
- User and password: the credentials, separated by a colon and ending with @.
- Host: the domain or IP of the database server.
- Port: where the engine listens; 5432 is PostgreSQL's default and 3306 is MySQL's.
- Database name: whatever follows the slash.
- Parameters: extra options after the question mark, such as sslmode to control encryption.
A common trap: if the password contains characters that mean something in a URI, such as @, :, / or #, they must be percent-encoded, or the driver will misread where each part ends. Many provider dashboards hand you an already-encoded URL, but double-check if you build it by hand.
Having it all in one variable means moving between environments or providers is a one-value change.
Documentation: PostgreSQL · Connection URIs (libpq) ↗
Same code locally and in production, with verified TLS#
The practical challenge is that conditions differ between local and production, especially encryption. Locally you usually connect to a database without TLS; in production, many managed providers require encrypted connections. The clean approach is to switch the TLS settings by environment while using the same DATABASE_URL:
const { Pool } = require("pg");
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is not set");
}
const isProduction = process.env.NODE_ENV === "production";
const pool = new Pool({
// Keep sslmode out of this URL: node-postgres would replace the ssl object
connectionString,
ssl: isProduction
? {
rejectUnauthorized: true, // verify the server certificate
ca: process.env.DATABASE_CA_CERT, // provider's PEM, if it uses its own CA
}
: false,
});An important correction to the earlier version of this guide: many tutorials set rejectUnauthorized: false to make the connection work. That encrypts traffic but accepts any certificate, so it doesn't protect you from someone impersonating your server. Verify the certificate instead and, if your provider runs its own certificate authority, pass its root certificate as ca.
One more node-postgres detail: if the URL includes sslmode, sslrootcert or similar options, the ssl object you pass in the config is replaced by whatever the URL says. Pick one place to configure TLS.
In libpq, PostgreSQL's official client library, sslmode=require encrypts the connection but, unless a root certificate is configured, doesn't verify the server's identity; verify-full checks the certificate chain and that the hostname matches. For production, verify-full is the safe choice.
And the opposite mistake: leaving TLS on locally when your development database doesn't support it makes the connection fail with an SSL error. That's why the setting is gated on isProduction.
Documentation: node-postgres · SSL ↗ · PostgreSQL · SSL support (sslmode) ↗
Where to keep secrets (and where never to)#
Not every place you can store a variable is equally safe. Here's the hierarchy:
| Where | Use it? | What for |
|---|---|---|
| Your deploy platform's variables or secrets panel | Yes | Production secrets, outside the repository |
| A dedicated secrets manager (AWS Secrets Manager, Vault, etc.) | Yes | Larger teams, rotation and auditing |
| A local, uncommitted .env file | Yes | Development on your machine only |
| A committed .env.example with no values | Yes | Documenting which variables must be set |
| Values hardcoded in source | Never | They live in the repo and its history |
| A real .env committed to the repository | Never | Anyone with repo access can read the credentials |
| Sensitive variables printed to logs | Never | Logs are stored, shared and indexed |
Three rules that prevent most leaks:
- Add .env to .gitignore in your very first commit. A credential that reaches Git history is hard to remove completely.
- Never print sensitive variables to logs. A secret in a log is an exposed secret.
- Commit a .env.example with variable names but no values, so your team knows what to set without exposing anything.
# .env.example (committed, no real values)
DATABASE_URL=
DATABASE_CA_CERT=
API_KEY=
NODE_ENV=development
# .gitignore
.env
.env.*
!.env.exampleIf you run on Kubernetes, note that Secret objects are stored unencrypted in etcd by default; the official docs recommend enabling encryption at rest and restricting access with RBAC.
Documentation: Git · gitignore ↗ · AWS · What is AWS Secrets Manager ↗ · Kubernetes · Secrets ↗ · OWASP · Secrets Management Cheat Sheet ↗
If a secret leaked#
If your .env file shows up in git log, or a secret made it into a shared log, treat those credentials as compromised. Deleting the file in a new commit isn't enough: the value is still in history and in every clone or fork.
- Rotate first: create new credentials in the database or provider and update the value in your platform's panel.
- Revoke the old credentials as soon as the deployment uses the new ones.
- Then, if needed, clean the history. GitHub documents the process with git-filter-repo; BFG Repo-Cleaner is another common tool.
- Turn on your code host's secret scanning, such as GitHub secret scanning, to catch future leaks.
Order matters: rewriting history without rotating leaves a valid credential in the hands of whoever already copied it.
Documentation: GitHub · Removing sensitive data from a repository ↗ · GitHub · About secret scanning ↗
How to check that your app reads the right variables#
When something won't connect, confirm what your app is actually receiving before you touch the code:
- Temporarily log whether the variable exists, not its value, for example with Boolean(process.env.DATABASE_URL).
- Check that the name matches exactly: on Linux, DATABASE_URL and Database_Url are different variables.
- Make sure the variable is declared in the right place: build time or runtime, and on the right service if you have several.
- Look for stray spaces or quotes around the value after pasting it into the panel.
- If the password has special characters, confirm they are encoded in the URL.
Documentation: Node.js · process.env ↗
Build-time vs runtime variables#
Build-time variables are available while the app is being compiled; runtime variables, while it's running. Some platforms inject only one kind, so confirm which phase needs each variable.
In frontend frameworks the difference is critical. In Next.js, variables prefixed with NEXT_PUBLIC_ are inlined into browser JavaScript at build time; in Vite, only variables prefixed with VITE_ are exposed to the client. Anything exposed that way is public, so never put DATABASE_URL or any other secret in a variable with those prefixes.
Documentation: Next.js · Environment variables ↗ · Vite · Env variables and modes ↗
Frequently asked questions#
Why shouldn't I write the database password in code? Because anyone with access to the repository and its history can read it, and changing it means redeploying. With environment variables you can rotate it without touching code.
What does sslmode=require at the end of DATABASE_URL do? It forces the connection to be encrypted. In PostgreSQL it doesn't, on its own, verify the server's identity; use verify-full with your provider's root certificate for that.
Why does my connection fail in production but work locally? Usually TLS: your local database doesn't need it, production does, or the certificate can't be verified. Gate the TLS config on the environment and check the root certificate.
Should I commit my .env file so the deploy works? No. Add it to .gitignore and commit a .env.example with variable names and no values instead.
Summary: DATABASE_URL without exposing credentials#
Handling DATABASE_URL in production comes down to three things: keep the connection string out of the code, declare it in your platform's environment, and check at startup that the app receives it and can connect over verified TLS.
- Validate that the variable exists at startup. An early, clear failure saves debugging time.
- Separate environments by value, not by code. One DATABASE_URL for local, another for staging, another for production; the code only reads the variable.
- Rotate credentials after any leak. Deleting the file isn't enough.
Most modern deployment platforms, including the major clouds, inject environment variables natively, so you don't need .env files in production. Your code stays the same across local, staging and production; only the value the environment provides changes.
Documentation: The Twelve-Factor App · III. Config ↗
Sources and scope
Documentation checked on September 25, 2026. Examples and decision criteria are editorial proposals; adapt them to your application's contract and validate them in an authorized test environment.
- The Twelve-Factor App · III. Config ↗
- Node.js · process.env ↗
- Python · os.environ ↗
- PostgreSQL · Connection URIs (libpq) ↗
- node-postgres · SSL ↗
- PostgreSQL · SSL support (sslmode) ↗
- Git · gitignore ↗
- AWS · What is AWS Secrets Manager ↗
- Kubernetes · Secrets ↗
- OWASP · Secrets Management Cheat Sheet ↗
- GitHub · Removing sensitive data from a repository ↗
- GitHub · About secret scanning ↗
- Next.js · Environment variables ↗
- Vite · Env variables and modes ↗
Compare cloud options
Review pricing, limits, conditions and sources for each option (in Spanish).
Open comparison