My simple `direnv` framework | Yoav Shai
4 min read
My simple `direnv` framework

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:

  1. 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.
  2. Rapid switching of environments, from production to staging in a single concise command.
  3. 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.

.envrc
# 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:

.zshrc
# ----- 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:

.env.base
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:

.env.dev
EXPO_PUBLIC_BACKEND_URL=http://127.0.0.1:3000

And the local dev environment holds the overrides for my machine:

.env.dev.local
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:

.env.local
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.