Skip to content
DevOps devops app-deployment 6 min read

Managing Environment Variables & Secrets

Every real application needs settings that change between your laptop, a staging server, and production: the database address, an API key, a port number. The clean way to supply these is through environment variables (named values the operating system hands to a running program), not by hard-coding them in your source files. This page shows you how to do that safely on Ubuntu, how to keep secret values out of Git and out of container images, and how to wire them into a systemd service so your app starts with the right config every time.

Why config belongs in the environment

The twelve-factor app methodology (a widely-followed set of rules for building deployable web apps) has a simple rule for configuration: store config in the environment, not in the code. The reasoning is practical.

  • The same build of your app should run anywhere — dev, staging, prod — with only the environment changing.
  • Secrets (passwords, tokens) must never be committed to version control. If they live in code, they live in your Git history forever.
  • Different people and machines need different values without editing code.

“Config” here means anything that varies by deploy: hostnames, credentials, feature flags, the listening port. It does not mean internal constants like the number of retries your code does — those belong in the code.

Treat every value you would be embarrassed to post publicly as a secret. If a key leaks, assume it is compromised and rotate (replace) it immediately. Git history makes leaks permanent, so prevention beats cleanup.

Setting variables in a shell

The quickest way to set a variable for a single command is to prefix it. To set it for the whole shell session, use export.

# Just for this one command
DATABASE_URL="postgres://localhost/app" node server.js

# For the rest of this shell session
export PORT=3000
echo "App will listen on $PORT"

Output:

App will listen on 3000

These vanish when the shell closes, so shells are good for quick tests but wrong for real deployments. For production you want something durable, which is where .env files and systemd come in.

Using a .env file

A .env file is a plain text file of KEY=value lines that holds your local config. Many frameworks load it automatically (Node.js via the built-in --env-file flag or the dotenv library; Python via python-dotenv). It keeps secrets out of your shell history and in one obvious place.

Create one in your project root:

cat > .env <<'EOF'
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://appuser:s3cr3t@localhost:5432/appdb
JWT_SECRET=change-me-to-a-long-random-string
EOF
chmod 600 .env

The chmod 600 (set file permissions so only the owner can read or write) matters — a .env full of passwords should not be world-readable.

The single most important step: keep .env out of Git. Add it to .gitignore and commit only a safe template.

echo ".env" >> .gitignore
cp .env .env.example   # then blank out the real values in .env.example

Commit .env.example (with placeholder values) so teammates know which variables they need, but never the real .env.

Run your app pointing at it (Node 20+ has native support):

node --env-file=.env server.js

.env vs shell export vs systemd — when to use which

MethodPersists across reboot?Good forAvoid when
export in shellNoQuick local testsAnything production
.env fileYes (file on disk)Local dev, simple deploysYou need OS-managed startup
systemd EnvironmentFileYesProduction services on UbuntuMany shared secrets across hosts
Secret managerYesTeams, compliance, rotationA tiny single-server hobby app

Passing env vars through systemd

On Ubuntu, long-running apps should be managed by systemd (the system service manager that starts, stops, and restarts background programs). A systemd service can inject environment variables in two ways: inline with Environment=, or from a file with EnvironmentFile=.

Create a service unit:

sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Node App
After=network.target

[Service]
User=appuser
WorkingDirectory=/opt/myapp
# A few non-secret values inline:
Environment=NODE_ENV=production
Environment=PORT=3000
# Secrets come from a protected file:
EnvironmentFile=/etc/myapp/secrets.env
ExecStart=/usr/bin/node server.js
Restart=on-failure

[Install]
WantedBy=multi-user.target

Store the secret file outside your code directory and lock it down so only root can read it:

sudo mkdir -p /etc/myapp
sudo tee /etc/myapp/secrets.env > /dev/null <<'EOF'
DATABASE_URL=postgres://appuser:s3cr3t@localhost:5432/appdb
JWT_SECRET=a-long-random-production-secret
EOF
sudo chmod 600 /etc/myapp/secrets.env
sudo chown root:root /etc/myapp/secrets.env

Note the EnvironmentFile format is bare KEY=value lines with no export and no shell quoting tricks — systemd is not a shell. Now reload and start:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

Output:

● myapp.service - My Node App
     Loaded: loaded (/etc/systemd/system/myapp.service; enabled)
     Active: active (running) since Mon 2026-06-15 10:22:14 UTC; 3s ago
   Main PID: 4821 (node)

You can confirm the variables reached the process:

sudo systemctl show myapp -p Environment

When to use which: use Environment= for non-secret, rarely-changing values you don’t mind seeing in the unit file. Use EnvironmentFile= for anything secret, so credentials live in a 600 root-owned file instead of a unit you might commit to Git.

Don’t bake secrets into images

If you build Docker images, never put secrets in the Dockerfile or in ENV lines. Anyone who pulls the image can run docker history and read every build argument and layer.

# BAD — secret is baked into the image forever
ENV JWT_SECRET=super-secret-value

Instead, leave secrets out of the build and pass them at runtime:

docker run --env-file /etc/myapp/secrets.env myapp:latest

The same principle applies to build arguments (--build-arg): they end up in image metadata, so they are not a safe place for secrets.

A preview of secret managers

.env files and EnvironmentFile= are great for one or a few servers. Once you have many servers, a team, or compliance needs, you graduate to a secret manager — a dedicated service that stores secrets encrypted, controls who can read each one, logs every access, and can rotate (auto-replace) credentials on a schedule. Common choices in 2026 include HashiCorp Vault, AWS Secrets Manager, Doppler, and Infisical.

The pattern is the same everywhere: your app (or a small startup script) authenticates to the manager, fetches the secrets it’s allowed to see, and exports them as environment variables before the app boots. The secrets never touch disk in plain text and never enter Git. You don’t need this on day one — start with .env and EnvironmentFile=, and reach for a manager when shared access and rotation become real problems.

Best Practices

  • Add .env (and any *.secrets) to .gitignore before your first commit, and commit a .env.example template instead.
  • Set chmod 600 on every file containing secrets, and own production secret files as root.
  • Keep secrets in EnvironmentFile=, not inline Environment=, and store that file outside your code directory.
  • Never put secrets in Dockerfiles, ENV lines, or --build-arg; pass them at runtime with --env-file.
  • Use distinct secret values per environment — never reuse a production password in staging.
  • Rotate any secret the moment it might have leaked, and prefer a secret manager once a team or rotation is involved.
Last updated June 15, 2026
Was this helpful?