How I Manage Secrets with 1Password Environments


Two stacks I run on my machines both deal with a lot of secrets.

The first is my home server, a docker-compose.yml with about 40 services. Cloudflare API keys for Traefik. A WireGuard private key for Gluetun. Database passwords for Immich and Paperless. A Meilisearch master key for KaraKeep. Most of those values are pulled in by Compose at up time from a .env file sitting next to the YAML.

The second is my Claude planning system. The skills that pull from TickTick, Oura, Fitbit, and EasyPost are small bash CLIs in ~/.claude/skills/, and each one needs an API token to do its job.

Both of these used to live in plaintext .env files. That worked fine until I started reaching for the same TickTick token from two different scripts, and then a third, and then I realized I had .env and .envrc and ad-hoc export FOO=... lines spread across three different shells. The values were in 1Password already. They just weren’t getting from there to the place that actually needed them.

So I switched both stacks over to 1Password Environments, and now every secret has exactly one home.

What op environment actually is

One thing to know up front: 1Password’s development environments are still in beta as I write this, so the exact UI and commands may shift over time. You add, edit, and delete them in the 1Password desktop apps for Mac, Windows, and Linux; on 1Password.com you can only manage who has access. To use them in your terminal, you need the beta version of the 1Password CLI.

1Password’s CLI (op) ships an environment namespace that lets you define a named bag of env vars whose values are 1Password secret references. You create one in the 1Password desktop app under Developer Tools → Environments, give it a name, and add KEY=op://Vault/Item/field lines. Each environment gets a stable ID, the same kind of identifier you’d see on a vault item.

You then have two ways to use it:

Terminal
# 1. Run a command with the env vars loaded into its process
op run --environment <ENV_ID> -- some-command
 
# 2. Read the resolved env vars to stdout (KEY=value lines)
op environment read <ENV_ID>

The first form is what you want when you have a child process that already knows how to read env vars (Docker Compose, a server binary, anything that calls getenv or reads process.env). The second is what you want when you only need a couple of values inside a script and you don’t want to hand the whole environment to whatever you’re running.

I use both. They serve different jobs.

Pattern 1: op run for the home server

The home server is the easy case. Compose already reads .env from the working directory at up time, and op run injects env vars into the child process exactly like Compose expects.

I have a ~/HomeServer/.homeserver/functions file that gets sourced from my shell, and it looks roughly like this:

~/HomeServer/.homeserver/functions
HOME_SERVER_DIR="/home/kevin/HomeServer"
 
# 1Password environment for `op run --environment` (see `op environment list`)
ENV_ID="xxxxxxxxxxxxxxxxxxxxxxxxxx"
 
start_home_server() {
    cd "$HOME_SERVER_DIR" && op run --environment "$ENV_ID" -- docker compose up -d "$@"
}
 
stop_home_server() {
    cd "$HOME_SERVER_DIR" && op run --environment "$ENV_ID" -- docker compose down "$@"
}
 
restart_home_server() {
    stop_home_server "$@"
    start_home_server "$@"
}

Now start_home_server is the only command I ever run to bring the stack up. op run resolves every op://... reference in the environment, exports the values into the child process, and Compose sees them as if they came from a regular .env file. The actual .env file in the repo is empty, just a marker for things that didn’t make it into 1Password yet.

A few things this gets me for free:

  • No plaintext secrets on disk. The .env.example I keep in the repo has placeholder values only. The real values never land in a file I’d accidentally cat, git diff, or copy into a chat thread.
  • Compose still works the same way. No syntax change to docker-compose.yml, no plugin, no sidecar. The values appear in the child environment and Compose interpolates them.
  • One source of truth. When the WireGuard provider rotates a key or I spin up a new database, I update the value in 1Password, and the next restart_home_server picks it up.

The one footgun: op run does $-expansion on the resolved env vars before handing them to the child process. If a secret contains a literal $, it gets eaten. I hit this with an htpasswd hash I needed for Traefik’s dashboard auth, and the fix was to stop trying to run that hash through op run at all. I keep a real .htpasswd file in appdata/traefik/ and mount it into the container instead. Not every secret wants to be an env var.

Pattern 2: op environment read for the Claude skills

The Claude side is structurally different and op run is the wrong tool.

A skill script like ~/.claude/skills/tt/ticktick is a small bash CLI that gets invoked dozens of times during a single Claude session. Each invocation needs exactly one secret: the TickTick OAuth token. I don’t want every invocation to spawn op run and resolve the entire environment, and I don’t want to keep the whole environment in the script’s process when it only cares about one variable.

So instead, the script reads from op environment directly and pulls only what it needs:

~/.claude/skills/tt/ticktick
#!/usr/bin/env bash
# ticktick - CLI wrapper for the TickTick Open API
set -euo pipefail
 
API="https://api.ticktick.com/open/v1"
 
# Resolve secrets from 1Password Development Environment (session cached)
if [[ -z "${TICKTICK_TOKEN:-}" ]]; then
  eval "$(op environment read "${CLAUDE_1P_DEV_ENV_ID:?CLAUDE_1P_DEV_ENV_ID is not set}" 2>/dev/null | grep '^TICKTICK_TOKEN=')" || true
fi
 
if [[ -z "${TICKTICK_TOKEN:-}" ]]; then
  echo "Error: TICKTICK_TOKEN not set." >&2
  exit 1
fi
 
# ...rest of the script uses $TICKTICK_TOKEN

There are four moving parts in those few lines and each one earns its keep:

  1. The if [[ -z "${TICKTICK_TOKEN:-}" ]] guard. If the variable is already in the environment (from a parent shell, or a previous skill call in the same session) we skip the op call entirely. op environment read is fast but not free, and the guard makes the script no-ops on warm calls.
  2. op environment read | grep '^TICKTICK_TOKEN='. Pull the entire resolved environment, then keep only the line for the variable this script cares about. The whole env never lands in the script’s memory.
  3. eval "$(...)". Take the surviving KEY=value line and evaluate it as a shell statement so $TICKTICK_TOKEN ends up exported in the current process.
  4. The hard fail at the bottom. If op failed, or the variable wasn’t in the environment, or any other reason the value didn’t materialize, exit non-zero with a clear message rather than silently making API calls without auth.

The same pattern shows up in oura, fitbit, deliveries, and a few others. Each one pulls only the variables it actually needs:

~/.claude/skills/stream/fitbit
if [[ -z "${FITBIT_CLIENT_ID:-}" ]] || [[ -z "${FITBIT_CLIENT_SECRET:-}" ]]; then
  eval "$(op environment read "${CLAUDE_1P_DEV_ENV_ID:?CLAUDE_1P_DEV_ENV_ID is not set}" 2>/dev/null | grep '^FITBIT_CLIENT_')" || true
fi

fitbit needs both FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET, so the grep pattern widens to ^FITBIT_CLIENT_. deliveries needs an EasyPost key plus the TickTick token, so it greps for both. The principle is the same: be explicit about what you’re loading.

The env ID lives in a per-machine env var

You’ll notice that the home server function hardcodes its environment ID, but the Claude skills don’t. They reference ${CLAUDE_1P_DEV_ENV_ID:?...}. That’s deliberate.

The home server runs on exactly one machine. The Claude skills run on whatever I happen to be sitting at, which is a Linux desktop most of the time and an M-series Mac the rest of the time. Each of those machines has its own 1Password account context, which means a different environment ID per OS. So I keep the ID in a per-OS env file:

~/.linux_env
export CLAUDE_1P_DEV_ENV_ID="xxxxxxxxxxxxxxxxxxxxxxxxxx"
~/.macos_env
export CLAUDE_1P_DEV_ENV_ID="yyyyyyyyyyyyyyyyyyyyyyyyyy"

And ~/.zshrc sources whichever one matches the current OS:

~/.common_env
case "$(uname -s)" in
    Darwin) [[ -r "$HOME/.macos_env" ]] && source "$HOME/.macos_env" ;;
    Linux)  [[ -r "$HOME/.linux_env" ]] && source "$HOME/.linux_env" ;;
esac

The :?CLAUDE_1P_DEV_ENV_ID is not set in the skill scripts is the safety net. If the env var isn’t set (unsourced shell, fresh login, missing dotfile), the script bails immediately with a clear error instead of trying to call op with an empty string and failing in a confusing way later.

Tradeoffs and notes

A few things I want to be honest about:

  • You still need op running. Both patterns assume the 1Password CLI is installed and signed in. On macOS this means biometric auth on first call; on Linux it means an unlocked desktop session. If op is locked, both patterns fail loudly. That’s usually what you want, but it’s worth knowing.
  • The eval in pattern 2 is doing real work. eval is correctly aimed at op output here, which is structured KEY=value lines, but you should be aware of what you’re evaluating. If you ever pipe untrusted input through eval, you have a different problem.
  • op run doesn’t help if the consuming program doesn’t read env vars. Compose, Node servers, Go binaries, anything reading process.env is fine. Anything that wants a config file already on disk wants a different approach (the htpasswd case above).
  • The environment ID isn’t actually a secret, it’s just a stable identifier within your 1Password account. I redact it in this post for cleanliness, not for security. Without a signed-in op session, the ID gets you nothing.
  • .env.example files still belong in repos. They document the shape of the environment, even when the values live in 1Password. I keep mine right next to docker-compose.yml with placeholder values, and the actual .env is empty.

Why this works for me

Two patterns, one source. Compose gets op run. The skills get op environment read | grep | eval. The home server’s environment ID is hardcoded because that script only ever runs on one machine. The Claude skills’ environment ID lives in a per-OS dotfile because the skills run wherever I do.

The thing that actually changed when I moved to this is the number of files I have to touch when a secret rotates. It used to be “find every place this token lives, update each one, redeploy.” Now it’s “update the value in 1Password.” The next op run or op environment read resolves to the new value automatically, and any process that’s already running with the old value either keeps using it (fine) or reads it again on the next call (also fine).

If you’re juggling secrets across personal tooling and you already pay for 1Password, op environment is one of those features that turns out to be exactly what you wanted once you start using it.