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:
# 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:
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.exampleI keep in the repo has placeholder values only. The real values never land in a file I’d accidentallycat,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_serverpicks 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:
#!/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_TOKENThere are four moving parts in those few lines and each one earns its keep:
- 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 theopcall entirely.op environment readis fast but not free, and the guard makes the script no-ops on warm calls. 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.eval "$(...)". Take the survivingKEY=valueline and evaluate it as a shell statement so$TICKTICK_TOKENends up exported in the current process.- The hard fail at the bottom. If
opfailed, 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:
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
fifitbit 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:
export CLAUDE_1P_DEV_ENV_ID="xxxxxxxxxxxxxxxxxxxxxxxxxx"export CLAUDE_1P_DEV_ENV_ID="yyyyyyyyyyyyyyyyyyyyyyyyyy"And ~/.zshrc sources whichever one matches the current OS:
case "$(uname -s)" in
Darwin) [[ -r "$HOME/.macos_env" ]] && source "$HOME/.macos_env" ;;
Linux) [[ -r "$HOME/.linux_env" ]] && source "$HOME/.linux_env" ;;
esacThe :?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
oprunning. 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. Ifopis locked, both patterns fail loudly. That’s usually what you want, but it’s worth knowing. - The
evalin pattern 2 is doing real work.evalis correctly aimed atopoutput here, which is structuredKEY=valuelines, but you should be aware of what you’re evaluating. If you ever pipe untrusted input througheval, you have a different problem. op rundoesn’t help if the consuming program doesn’t read env vars. Compose, Node servers, Go binaries, anything readingprocess.envis 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
opsession, the ID gets you nothing. .env.examplefiles still belong in repos. They document the shape of the environment, even when the values live in 1Password. I keep mine right next todocker-compose.ymlwith placeholder values, and the actual.envis 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.