﻿---
title: Set System Env-Vars in UNIX
date: 2025-07-27
excerpt: How to set true system-wide environment variables on macOS with a simple Launch Agent and shell script, so GUI apps and every shell see them.
tags:
  - OS
  - Shell
  - Terminal
  - macOS
  - Process
  - Daemon
  - XDG
  - Nushell
  - Linux
cover: https://assets.vluv.space/cover/Dev/tahoe.webp
updated: 2026-07-08 21:24:00
lang: en
i18n:
  cn: /unix_environment_variables
  translation: 2
---

## Intro

Many tutorials on setting macOS environment variables suggest putting them in files like `~/.bashrc` or `$ZDOTDIR/.zshrc`. That works, but it has limits:

1. If you switch between different shells (e.g. Nushell, Zsh), you have to maintain multiple sets of configuration
2. GUI apps cannot read environment variables from shell config files. For example, if you set `$EDITOR=nvim` in `$ZDOTDIR/.zshrc`, Kitty won't see it[^1]
3. **Bootstrapping issue**: variables like `ZDOTDIR` exist to tell the shell **"where to read its configuration from"**. Defining it **inside** the config file is like locking the key inside the safe — the shell still looks in the default location at startup, so the variable loses its bootstrapping purpose.

> [!NOTE]-
>
> **Shell variable**: a variable that only exists in the current shell session. It is invisible to child processes started by that shell
>
> ```shell
> (zsh)$ x=1
> (zsh)$ echo $x
> 1
> (zsh)$ bash
> (bash)$ echo $x
> [NO-OUTPUT]
> ```
>
> **Environment variable**: a special kind of variable that is not only valid in the current shell session, but is also passed to every child process the shell starts;
>
> ```shell
> (zsh)$ export x=1
> (zsh)$ echo $x
> 1
> (zsh)$ bash
> (bash)$ echo $x
> 1
> ```
>
> Read More: [export command in Linux with Examples - GeeksforGeeks](https://www.geeksforgeeks.org/linux-unix/export-command-in-linux-with-examples/)

## Example Case

As a demonstration, let's set the `XDG_*` family of variables. For an introduction to the XDG specification, see [XDG Base Directory - ArchWiki](https://wiki.archlinux.org/title/XDG_Base_Directory).

> [!info]
>
> macOS uses the `launchd` process to manage daemons and agents, and you can also use it to run shell scripts. You don't interact with [launchd](x-man-page://launchd) directly; instead, you use the [launchctl](x-man-page://launchctl) command to load or unload `launchd` daemons and agents.
>
> During system boot, `launchd` is the first process the kernel runs to set up the computer. If you want a shell script to run as a daemon, it should be started by `launchd`. Other mechanisms for starting daemons and agents may be removed by Apple at its discretion.
>
> You can learn about the various daemons and agents managed by `launchd` by looking at the configuration files in these folders:
>
> | Folder                          | Purpose                                              |
> | ------------------------------- | ---------------------------------------------------- |
> | `/System/Library/LaunchDaemons` | System daemons provided by Apple                     |
> | `/System/Library/LaunchAgents`  | Per-user agents provided by Apple for all users      |
> | `/Library/LaunchDaemons`        | Third-party system daemons                           |
> | `/Library/LaunchAgents`         | Third-party per-user agents that apply to all users  |
> | `~/Library/LaunchAgents`        | Third-party agents that apply only to the logged-in user |

### Step 1: Create the Environment Setup Script

First, create a shell script anywhere you like, e.g. `~/.local/bin/sys_envs`.

```bash
touch ~/.local/bin/sys_envs
```

The script is shown below. On macOS it uses `{shell} launchctl setenv` to set environment variables.

```bash sys_envs
#! /bin/bash

# Define a macro function for setting environment variables
env() {
    os_name=$(uname -s)

    case "$os_name" in
        Darwin)
            # For macOS, use launchctl to set environment variables
            launchctl setenv "$1" "$2"
            # launchctl setenv does not work for current shell session(which executes this script),
            # for example, when set `XDG_CONFIG_HOME/bat`, the XDG_CONFIG_HOME will be expanded as empty string.
            export "$1=$2"
            ;;
        Linux)
            export "$1=$2"
            # Replace $HOME with ~ for /etc/environment
            env_value="${2//$HOME/~}"
            # Append to /etc/environment with sudo
            echo "$1=$env_value" | sudo tee -a /etc/environment > /dev/null
            ;;
        *)
            echo "Unsupported OS: $os_name"
            exit 1
            ;;
    esac
}

env XDG_BIN_HOME "$HOME/.local/bin"
env XDG_CACHE_HOME "$HOME/Library/Caches"
env XDG_CONFIG_HOME "$HOME/.config"
env XDG_CONFIG_DIRS "/etc/xdg"
env XDG_DATA_HOME "$HOME/.local/share"
env XDG_DATA_DIRS "/usr/local/share/:/usr/share/"
env XDG_STATE_HOME "$HOME/.local/state"

# https://wiki.archlinux.org/title/XDG_user_directories
env XDG_DESKTOP_DIR "$HOME/Desktop"
env XDG_DOCUMENTS_DIR "$HOME/Documents"
env XDG_DOWNLOAD_DIR "$HOME/Downloads"
env XDG_MUSIC_DIR "$HOME/Music"
env XDG_PICTURES_DIR "$HOME/Pictures"
env XDG_PUBLICSHARE_DIR "$HOME/Public"
env XDG_VIDEOS_DIR "$HOME/Movies"

# Define paths for common programs with partial XDG support
# https://wiki.archlinux.org/title/XDG_Base_Directory#Partial
env CARGO_HOME "$XDG_DATA_HOME/cargo"
env FFMPEG_DATADIR "$XDG_CONFIG_HOME/ffmpeg"
env LESSHISTFILE "$XDG_STATE_HOME/less_history"
env MYPY_CACHE_DIR "$XDG_CACHE_HOME/mypy"
env NODE_REPL_HISTORY "$XDG_STATE_HOME/node_repl_history"
env PYENV_ROOT "$XDG_DATA_HOME/pyenv"
env PYTHONPYCACHEPREFIX "$XDG_CACHE_HOME/python"
env PYTHONUSERBASE "$XDG_DATA_HOME/python"
env PYTHON_HISTORY "$XDG_STATE_HOME/python_history"
env RIPGREP_CONFIG_PATH "$XDG_CONFIG_HOME/ripgrep/config"
env RUSTUP_HOME "$XDG_DATA_HOME/rustup"
env WORKON_HOME "$XDG_DATA_HOME/virtualenvs"
# docker
env DOCKER_CONFIG "$XDG_CONFIG_HOME/docker"
env MACHINE_STORAGE_PATH "$XDG_DATA_HOME/docker_machine"
# npm
env NPM_CONFIG_USERCONFIG "$XDG_CONFIG_HOME/npm/npmrc"
# zsh
env ZDOTDIR "$XDG_CONFIG_HOME/zsh"
env ZSH_PROFILE "$XDG_CONFIG_HOME/zsh/profile"
env HISTFILE "~/.cache/zshhistory"
# yazi
env YAZI_CONFIG_HOME "$XDG_CONFIG_HOME/yazi"
```

#### NuShell Rewrite-Version

The Bash script above writes to `/etc/environment`, which only supports plain key-value pairs. `/etc/profile.d/*.sh` files, on the other hand, are shell scripts, so values can reference other variables, which is a bit more flexible. Here is a Nushell version of the script that writes Linux environment variables to `/etc/profile.d/gnix-env.sh`

> [!NOTE]
>
> For my own cross-platform needs, setting environment variables is wrapped in an `env` function. On Arch Linux, system-wide environment variables can go into `/etc/environment` or `/etc/profile.d/*.sh`
>
> For per-user settings, consider writing to `~/.pam_environment`; see [pam_env.conf(5) - Linux man page](https://linux.die.net/man/5/pam_env.conf)

```nu sys_envs.nu
#! /usr/bin/env nu

# Define a function to set environment variables cross-platform
def --env env [name: string value: string] {
  # Set env var for current session
  load-env {$name: $value}
  match $nu.os-info.name {
    "macos" => {
      launchctl setenv $name $value
    }
    "linux" => {
      let env_file = "/etc/profile.d/gnix-env.sh"
      touch $env_file
      let lines = (open $env_file | lines | where not ($it | str starts-with $"export ($name)="))
      let env_value = ($value | str replace $nu.home-path "$HOME")
      let updated = ($lines | append $"export ($name)=\"($env_value)\"")
      $updated | str join (char nl) | save --force $env_file
    }
    "windows" => {
      setx $name $value
    }
  }
}
```

### Step 2. Load the Script at Login/StartUp

Create and edit the `~/Library/LaunchAgents/env.plist` file

```xml ~/Library/LaunchAgents/env.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>com.user.environment-vars</string>
    <key>ProgramArguments</key>
    <array>
      <!-- Replace this with the path to your own script -->
      <string>/Users/gjx/.local/bin/sys_envs</string>
    </array>
    <key>RunAtLoad</key>
    <true />
    <key>KeepAlive</key>
    <false />
  </dict>
</plist>
```

Once these steps are done, `launchd` will run the script automatically at your next login, setting all the environment variables.

## Limitations

Apple does not guarantee the exact order in which `launchd` loads services. Your terminal might restart before `env.plist` gets loaded, leaving the terminal without the new environment variables.

Workarounds:

- **Log in again**: Log Out (shortcut `⌘ + ⇧ + Q`), then Log back in
- **Manually restart the app**: exactly what it says
- **Disable session restore**: in System Settings, turn off "Reopen windows when logging back".

Also, `PATH` cannot be modified via `launchctl`. To set `PATH` at the system-wide level, edit `/etc/paths`.

## Ref

- [Script management with launchd in Terminal on Mac - Official Apple Support](https://support.apple.com/guide/terminal/apdc6c1077b-5d5d-4d35-9c19-60f2397b2369/mac)
- [Setting environment variables via launchd.conf no longer works in OS X Yosemite/El Capitan/macOS Sierra/Mojave? - Stack Overflow](https://stackoverflow.com/questions/25385934/setting-environment-variables-via-launchd-conf-no-longer-works-in-os-x-yosemite)
- [EnvironmentVariables - Community Help Wiki](https://help.ubuntu.com/community/EnvironmentVariables#Persistent_environment_variables)

[^1]: [Opening preferences uses vim even though \$EDITOR is set to nvim · Issue #580 · kovidgoyal/kitty](https://github.com/kovidgoyal/kitty/issues/580)
