Getting into mobile and web development, I found myself leaning more on .env
files for
managing various configurations. After looking around for a nice way to manage them
I found direnv
, but could not find a single guide that made me happy. I’m sharing
the configuration I ended up using, no guarantees it’ll make you happy too.
The Premise
I wanted to have:
- A unified environment across all of my shell sessions. It’s very rare for me to need two shells to use different environments. I’m either developing or in production, no in between.
- Rapid switching of environments, from production to staging in a single concise command.
- All config that can be stored in git should be there. Only secrets and device-specific configuration is gitignored and set locally.
The Setup
It consists of this basic .envrc
file, and many .env
files alongside it. We start by loading
.env.base
which hosts the stuff all environments share (such as the EAS project id) and
.env.local
which contains all local-specific configuration (mostly secrets).
The content of the .envrc.current
file are then read as the name of the current environment, loading the generic (.env.<current>
) and device-specific (.env.<current>.local
) configuration from it.
It also makes sure to watch the .envrc.current
file for changes (which could come from other terminals) and reloads the env then.
# Load the base variables shared across all environments
dotenv_if_exists ".env.base"
# Persisted mode file (per project)
CURRENT_ENV_FILE=".envrc.current"
# Re-evaluate when the mode file changes
watch_file "$CURRENT_ENV_FILE"
# Only load app_env-specific files if mode file exists
if [[ -f "$CURRENT_ENV_FILE" ]]; then
app_env="$(<"$CURRENT_ENV_FILE")"
# Only proceed if app_env is not empty
if [[ -n "$app_env" ]]; then
echo "direnv: loading environment $app_env"
dotenv_if_exists ".env.$app_env"
source_env ".env.local"
source_env ".env.$app_env.local"
fi
else
# Load just .env.local if no mode file
source_env ".env.local"
fi
The .envrc
file is complemented with a .zshrc
configuration helping manage the peripherals:
# ----- custom direnv wrapper with a 'use' subcommand ----
direnv() {
if [[ "${1-}" == "use" ]]; then
# Find the nearest directory upward that contains .envrc
local dir="$PWD"
while [[ "$dir" != "/" && ! -f "$dir/.envrc" ]]; do
dir="$(dirname "$dir")"
done
if [[ ! -f "$dir/.envrc" ]]; then
echo "direnv: couldn't find a .envrc in this directory or any parent." >&2
return 1
fi
shift
local mode="${1-}"
if [[ -z "$mode" ]]; then
cat "$dir/.envrc.current"
return 0
fi
# Persist the mode for all shells
echo "$mode" > "$dir/.envrc.current"
else
command direnv "$@"
fi
}
# ---- completion for: direnv set <mode> ----
# Completes modes from files matching .env.* (shows only the suffix after ".env.")
_direnv_use_complete() {
local -a modes
local dir=$PWD
# If we're completing the third word and the second word is "set"
if (( CURRENT == 3 )) && [[ ${words[2]} == set ]]; then
# Find the nearest project root (folder with .envrc), walking up
while [[ "$dir" != "/" && ! -f "$dir/.envrc" ]]; do
dir=${dir:h}
done
[[ -f "$dir/.envrc" ]] || dir=$PWD
# Collect suffixes from .env.* files
local f base m
for f in "$dir"/.env.*(N); do
base=${f:t} # filename only
m=${base#.env.} # strip prefix
[[ -n "$m" && "$m" != "$base" ]] && modes+="$m"
done
# Deduplicate
typeset -U modes
# Offer completions (if any)
if (( ${#modes} )); then
_describe -t modes 'environment' modes
return
fi
fi
# Fallback: default file completion
_files
}
# Bind the completer to the 'direnv' command name
compdef _direnv_use_complete direnv
The zsh
function is responsible for the introduction of a new subcommand, direnv use
, which writes to the .direnv.current
file,
and for the completion with possible env names.
Usage
My .env.base
contains the stuff that holds true for the entire project:
EXPO_PUBLIC_SUPERWALL_API_KEY=pk_V_FQ4CsJrTEUBxFkPW3xtY
# This is the default (production) value; other .env files, loaded later, can override it to use a staging env.
EXPO_PUBLIC_INSTANTDB_APP_ID=2e9248bd-8188-45a2-84dd-9b79b74d4d97=..
While .env.dev
contains dev specific stuff:
EXPO_PUBLIC_BACKEND_URL=http://127.0.0.1:3000
And the local dev environment holds the overrides for my machine:
EXPO_PUBLIC_BACKEND_URL=http://192.168.1.53:3000 # For allowing physical iOS devices to connect
At this point you can probably imagine quite well what the rest of them look like.
The only other interesting thing to note is my .env.local
- it’s mostly used for secrets, and being loaded last allows it to not just override, but also adapt to the rest of the environment:
OPENAI_API_KEY=sk-proj-o26I52uiCJy4PkEb3Y01-TvGaFNiPl-ZFxrYhqdesoa_hmMSQ
case "$EXPO_PUBLIC_INSTANT_APP_ID" in
c02acdea-c1c5-4503-849f-c836142299db)
export INSTANT_ADMIN_TOKEN=5cc0150b-1426-4368-a790-c35bfc1ed1a2
;;
48d24b4e-e4cf-410d-a243-6ea7faf1470a)
export INSTANT_ADMIN_TOKEN=3ab32334-6169-4ca9-90de-51bc392b9c06
;;
*)
echo "Warning: unknown INSTANT_APP_ID '$EXPO_PUBLIC_INSTANT_APP_ID'"
;;
esac
Hope you can put it to good use, also note that no real API keys were harmed in the making of this blogpost so no there’s no point in trying.