Operations
envless is file-based, so day-two operations are largely Git
operations plus a small set of key-management steps. This page covers
the four scenarios you will hit: onboarding a teammate, reviewing
recipient changes, recovering from a disaster, and integrating with
CI/CD.
Team onboarding
envless treats every team member as a public key. Onboarding is one
PR: the newcomer generates an identity, posts their pubkey, and the
existing team re-encrypts to include them.
The newcomer’s side
# 1. Clone the repo.git clone git@github.com:org/repo.gitcd repo
# 2. Generate a local identity. The repo already has secrets/*.env.enc# and .envless/recipients — your `init` only creates your own# keypair if missing.envless init
# 3. Print your pubkey so you can paste it into a PR.grep "public key" .envless/identity.key# → # public key: age1abcd...The newcomer does not yet have decrypt access — their pubkey is not in
.envless/recipients.
The team’s side
A maintainer adds the newcomer’s pubkey and re-encrypts.
# 1. Append the new pubkey.echo "age1abcd..." >> .envless/recipients
# 2. Re-encrypt every env so the new recipient is in the data-key# wrap. v0.0.1 has no `envless re-encrypt`; the portable approach:for env in dev prod; do for key in $(envless list --env="$env"); do val=$(envless get "$key" --env="$env" --confirm) printf '%s' "$val" | envless set "$key" --env="$env" donedone
# 3. Commit.git checkout -b onboard-alicegit add .envless/recipients secrets/git commit -m "onboard alice: add recipient + re-encrypt envs"git push -u origin onboard-alicegh pr create --fillThe PR diff makes the change auditable: one new line in recipients
and N changed lines in secrets/*.env.enc. The encrypted-bytes diff
is noise, but the recipients diff is human-readable and reviewable.
After merge, the newcomer pulls and runs envless list to confirm
decrypt access.
Onboarding agents and CI runners
Identical pattern with a different name on the pubkey. Agents and CI get their own keypairs — never share a human’s identity with a bot.
# On the CI runner (or one-shot, key stored in GH Actions secret):age-keygen -o /tmp/ci-identity.keyPUBKEY=$(grep "public key" /tmp/ci-identity.key | awk '{print $4}')# Add PUBKEY to .envless/recipients via PR.# Store /tmp/ci-identity.key contents as the GH secret AGE_IDENTITY.See CI/CD integration below for the workflow side.
Anti-patterns to avoid
- One identity for the whole team. Defeats per-recipient revocation.
- Committing
identity.key. It is gitignored by default; double-check before pushing. - Sending pubkeys over Slack as the source of truth. The
.envless/recipientsPR is the source. Slack is a notification channel, not an audit trail. - Adding a pubkey without re-encrypting. Until you re-encrypt, the new recipient cannot decrypt anything. The PR should bundle both changes.
Tooling improvements coming in v0.1
envless team add alice@org --pubkey=age1...— one-command recipient addition.envless team revoke alice@org— removal + automatic re-encrypt..envless/team.yaml— per-env recipient lists with role names.
Until then, the manual flow above is the supported workflow.
Recipient management
.envless/recipients is the access-control plane for envless.
Every encrypted file is encrypted to the keys listed here. Adding a
line grants future read access; removing a line stops future writes
from including the recipient — but does not retroactively redact past
encryptions.
Format
Plain text, one age public key per line. Lines starting with #
are comments. Blank lines are ignored.
# Humansage1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # aliceage1z6f0ldygfp22k0w50zss5pl4kxvkzlulhpqcmm8m65rt6q08vrnsq8ec6t # bob
# CIage1mh6gh39mhqaeu2cysr0gwz2ts83krppyzj8nrz86g2yax7gx7scqgca2x9 # gh-actions-deployComments are useful for owner labels but are not part of the access
decision — sops only sees the keys.
Source-of-truth semantics
The file in the default branch of the repository is authoritative.
A local edit means nothing until it lands in main. This is by
design: PR review is the access-control workflow.
The parser (store.recipients()) treats lines like this:
var it = std.mem.splitScalar(u8, data, '\n');while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0 or line[0] == '#') continue; try out.append(try allocator.dupe(u8, line));}A trailing inline # comment on the same line as a pubkey is kept
as part of the key. Put comments on their own line.
If the file is empty after stripping comments, every encryption call
fails with store: no recipients in <path>.
Reviewing changes
Diffs to .envless/recipients are the moment to slow down. A PR
that touches this file should pass a checklist:
- Does the PR identify the human/agent/CI runner behind each new key?
- Does the same PR re-encrypt every
secrets/*.env.enc? (See Onboarding above for the loop.) - For removals: have the corresponding secrets been rotated upstream (see Security → Rotation)?
A CODEOWNERS rule pinning .envless/** to a security reviewer is a
good template for teams larger than three.
Multi-environment caveat (v0.0.1)
Today there is one .envless/recipients file for the entire repo.
Every env (dev, staging, prod) uses the same recipient list. If
you need per-env access — e.g. ops sees prod, devs see dev — you have
two interim options:
- Two repos. One per trust tier. Crude but secure.
- Wait for v0.1’s
.envless/team.yamlwhich introduces per-env recipient roles.
Programmatic listing
# Stable, scriptable list of pubkeysgrep -v '^#' .envless/recipients | grep -v '^$' | awk '{print $1}'envless itself does not yet expose envless recipients list — that
is a v0.1 convenience.
Disaster recovery
envless is file-based, so disaster recovery is largely Git recovery
plus a small set of key-management steps. The scenarios below cover
the realistic failure modes.
Scenario 1 — Identity file lost (no leak)
You deleted .envless/identity.key, or it lived on a wiped laptop,
but you have no reason to believe anyone else has a copy.
- On a fresh checkout, run
envless init. A new identity is generated. - Get a teammate (or any existing recipient) to add your new pubkey
to
.envless/recipientsand re-encrypt — see Team onboarding.
You temporarily lose the ability to decrypt until step 2 lands. The encrypted data is intact.
Scenario 2 — Identity file leaked
Assume the worst: someone has a copy of .envless/identity.key.
- Rotate every secret value at the upstream provider. The leaked key can decrypt all current and historical encrypted files in Git. See Security → Rotation.
- Remove the leaked pubkey from
.envless/recipients. - Generate a new identity (
envless initafterrm .envless/identity.key) and add it to recipients. - Re-encrypt every env with the new recipient set.
- Communicate to the team — the leaked identity is permanently useless after step 1, but document the timeline.
Do NOT skip step 1. Revoking the recipient stops future encryptions from including the leaked key, but the attacker already has copies of the encrypted files via Git history.
Scenario 3 — Last remaining identity holder is gone
The only person who could decrypt left, and they’re not reachable.
If no other recipient has access, the secrets are unrecoverable from the encrypted files. This is by design — there is no backdoor. Your recovery path:
- Recover secret values from their upstream sources (provider
dashboards, prior
.envbackups, the team password manager, etc.). - Initialize a fresh envless state on a clone:
Terminal window rm -rf .envless secretsenvless init# then `envless set` each recovered value - Commit and force-push, or replace history if your repo policy requires it.
Mitigation: always keep at least two recipients with decrypt access — e.g. one human and one bot identity stored encrypted in a password manager.
Scenario 4 — Repository gone (host loss)
Git is distributed. Any team member who pulled recently has the encrypted files locally. To recover:
- Identify the most recent clone.
git push --mirrorto a new remote.- Resume normal operation.
The .envless/identity.key files are not in Git, so each
developer’s local copy is still valid against the recovered repo.
Scenario 5 — Corrupted secrets/<env>.env.enc
The file fails to decrypt — sops errors with MAC mismatch or YAML
parse failure.
git log -p secrets/<env>.env.enc— find the last known-good commit.git checkout <good-sha> -- secrets/<env>.env.enc.- Decrypt and commit. The intervening encrypted edits are lost; merge from a teammate’s working copy if they have newer values.
If no good commit exists in history (e.g. a bad commit landed in main weeks ago and propagated), follow Scenario 3 — recover from upstream sources.
Backups worth keeping
.envless/identity.keyin an offline password manager (1Password, Bitwarden) per identity holder.- Periodic
git bundle createof the repo, stored off-host, if your Git host is the only copy.
The encrypted files in Git are not sensitive on their own — they are just bytes. The keys are.
CI/CD integration patterns
envless shines in CI: the pipeline holds one age secret key, and
every encrypted file in the repo is suddenly readable. No
per-environment env-var maze, no drift between local .env and
gh secrets.
The integration shape is the same on every CI:
- Generate a CI bot identity (an age keypair).
- Add its pubkey to
.envless/recipientsand re-encrypt. - Store the secret key as a single CI-provider secret
(
AGE_IDENTITYor similar). - In the workflow, write that secret to a temp file and point
envlessat it.
GitHub Actions
name: deploy
on: push: branches: [main]
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: install age + sops run: | sudo apt-get update sudo apt-get install -y age curl -sSfL -o /tmp/sops \ https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 sudo install -m 0755 /tmp/sops /usr/local/bin/sops
- name: install envless run: | # release tarballs are named envless_<version>_<target>.tar.gz # and each unpacks to envless_<version>_<target>/envless VER=v0.0.1 curl -sSfL "https://github.com/biliboss/envless/releases/download/$VER/envless_${VER}_x86_64-linux-gnu.tar.gz" \ | sudo tar -xz --strip-components=1 -C /usr/local/bin "envless_${VER}_x86_64-linux-gnu/envless"
- name: provision identity from secret run: | mkdir -p .envless umask 077 printf '%s' "${{ secrets.AGE_IDENTITY }}" > .envless/identity.key
- name: deploy with secrets injected run: envless exec --env=prod -- npm run deployThe envless exec line is the only place secrets enter the build —
they live inside the deploy step’s child process only.
GitLab CI
deploy: image: alpine:3.20 before_script: - apk add --no-cache age curl - curl -sSfL -o /tmp/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - install -m 0755 /tmp/sops /usr/local/bin/sops - VER=v0.0.1; curl -sSfL "https://github.com/biliboss/envless/releases/download/$VER/envless_${VER}_x86_64-linux-gnu.tar.gz" | tar -xz --strip-components=1 -C /usr/local/bin "envless_${VER}_x86_64-linux-gnu/envless" - mkdir -p .envless && umask 077 && printf '%s' "$AGE_IDENTITY" > .envless/identity.key script: - envless exec --env=prod -- ./deploy.sh variables: AGE_IDENTITY: $AGE_IDENTITY # masked CI/CD variableArbitrary Docker-based pipeline
Same shape, in a Dockerfile:
FROM alpine:3.20 AS buildRUN apk add --no-cache age curl \ && curl -sSfL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \ && chmod +x /usr/local/bin/sopsRUN curl -sSfL https://github.com/biliboss/envless/releases/latest/download/envless_v0.0.1_x86_64-linux-gnu.tar.gz \ | tar -xz --strip-components=1 -C /usr/local/bin envless_v0.0.1_x86_64-linux-gnu/envless
WORKDIR /srcCOPY . .RUN --mount=type=secret,id=age_identity \ mkdir -p .envless \ && cp /run/secrets/age_identity .envless/identity.key \ && chmod 0600 .envless/identity.key \ && envless exec --env=prod -- ./build.shBuild with docker build --secret id=age_identity,src=$HOME/.config/envless/ci.key ..
Hardening tips
- One bot identity per pipeline — separate keys for
stagingandprod. Lets you revoke independently. - No
envless getin CI. Useenvless execso plaintext only exists in the child process’s env, never in CI logs. umask 077before writing the identity file — defaults vary across runners; this is one line of insurance.- Mask the
AGE_IDENTITYsecret. Every major CI provider supports this; it scrubs accidental echo from logs. - Pin sops + age versions — let the workflow break on upgrade rather than silently picking up a new release.
What v0.1 will simplify
envless-ci-githubplugin — emits the workflow snippet above for you.envless ci provision— single command that creates a bot identity, prints the pubkey, and outputs the secret value to paste intogh secret set.
Until then, the snippets above are the supported pattern.
Daemon mode
envless daemon is an opt-in UNIX-socket service that caches
decrypted env in memory so repeated reads do not re-spawn sops. The
target use case is an agent loop (Claude Code, Cursor, etc.) that calls
envless dozens of times per minute — without the daemon, each call
pays the sops cold-start tax (~80-150ms); with it, lookups are
sub-millisecond after the first decrypt.
Tradeoff
The daemon holds plaintext secrets in process memory for up to 60s per
(repo, env) pair. A local attacker with ptrace privileges (root, or
on macOS the user themselves) can read that memory. The CLI’s
default path, by contrast, holds plaintext only for the lifetime of one
sops decrypt call — usually under 100ms.
If your threat model treats the local user as trusted (your laptop, no shared accounts, full-disk encryption), the daemon is fine. If multiple humans share an account, or if untrusted processes might run under your UID, leave it off and pay the per-call cost.
The full threat model is in Security under the “Local attacker (ptrace tier)” bullet.
Installing
# macOS — installs a LaunchAgent at ~/Library/LaunchAgents/.envless daemon install
# Linux — installs a user systemd unit at ~/.config/systemd/user/.envless daemon installBoth paths run the daemon under your user account (not root) and start it immediately. Status:
envless daemon status# [envless daemon] socket: /run/user/1000/envless/sock (present)# [launchd] plist: /Users/you/Library/LaunchAgents/io.github.biliboss.envless.plist (present)# [launchd] launchctl print OK …Uninstalling
envless daemon uninstallThis boots the service out (macOS) or disables the unit (Linux) and deletes the plist/unit file. The socket is removed on next daemon shutdown.
Running in the foreground
For ad-hoc debugging or running the daemon under a different process
manager (tmux, foreman, …), invoke envless daemon with no
sub-argument. The process logs to stderr and exits cleanly on
SIGTERM/SIGINT.
Wire-protocol consumers
envless mcpauto-detects the socket on every tools/call and routes reads through it when present. No flag needed.- The plain CLI (
envless list,envless set, etc.) does NOT consult the daemon. CLI invocations are one-shot — the sops cost is amortised across&&-chained calls, so the latency win is too small to justify the security tradeoff.
Backup & restore
envless keeps its state in plain files under the repo
(.envless/recipients, secrets/*.env.enc) plus one secret on disk
(.envless/identity.key). The committed files ride along with your
normal Git backups; the identity key needs a different home. The
envless backup subcommand bundles the safe-to-share pieces into a
single tar.gz and refuses, by default, to include the identity key.
What to back up
.envless/recipients— plaintext age public keys. Safe to back up anywhere. It is already in Git.secrets/*.env.enc— sops-encrypted ciphertext. Useless to an attacker without a matching age secret key. Safe in any backup destination. Already in Git..envless/identity.key— the age secret key. Treat it like a password: anyone who reads this file can decrypt every secret you have access to. It is gitignored by default and should never enter a cloud sync you do not control end-to-end.
The first two are encrypted-or-public artefacts: putting them in a public S3 bucket is unwise (it advertises the layout of your secrets) but cryptographically harmless. The third is a credential.
envless backup — the safe default
# Bundle .envless/recipients + secrets/*.env.enc + a MANIFEST.json# into a tarball you can ship anywhere.envless backup --output backup-$(date -u +%Y%m%d).tar.gzOutput (on stderr — stdout is reserved for streaming the tarball when
no --output is given):
BACKUP out=backup-20260604.tar.gz identity=excludedThe tarball contains:
MANIFEST.json # archive metadata (see schema below).envless/recipients # the public-key access listsecrets/dev.env.enc # every encrypted env, one file eachsecrets/prod.env.encThe identity key is not included. Restoring this tarball on a new machine gets you the encrypted artefacts; you still need an age key with read access to actually decrypt anything.
envless backup resolves the repo root by walking up from the
current directory looking for .envless/identity.key, so it works
from anywhere inside the repo. If no .envless/ is found, it exits
with code 64 (configuration error).
Streaming to stdout
Omit --output (or pass --output -) and the tarball is written to
stdout. This composes cleanly with cloud-upload CLIs:
envless backup | rclone rcat gdrive:envless-backups/$(date -u +%Y%m%d).tar.gzenvless backup | aws s3 cp - s3://my-bucket/envless-$(date -u +%Y%m%d).tar.gzenvless backup | gpg --symmetric --cipher-algo AES256 > backup.tar.gz.gpgManifest schema
The MANIFEST.json member documents the archive:
{ "schema_version": 1, "envless_version": "v0.1.0", "created_at": "2026-06-04T12:34:56Z", "repo_root": "/Users/alice/my-app", "pubkey": "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", "includes_identity": false, "envs": ["dev", "prod"], "file_count": 4}schema_version: integer. Bumped on breaking changes.envless_version: the binary that wrote the tarball.created_at: UTC, ISO 8601, second precision.repo_root: absolute path on the host that created the backup — for diagnostics; does not affect restore.pubkey: the local identity’s age pubkey. Useful for verifying that the right recipient list shipped.includes_identity: whether.envless/identity.keyis in the tarball. The CLI sets this from the--include-identityflag.envs: lexicographically sorted env names found undersecrets/.file_count: total members in the tarball, including the manifest itself.
Upload destinations (safe tarball)
For the default tarball — the one with includes_identity: false —
any of the following is acceptable.
Google Drive via MCP
If you wire the @modelcontextprotocol/server-gdrive
MCP server into your agent client, you get one-shot uploads. Example
workflow snippet:
# Produce the tarball, copy to a scratch path, then ask the agent to# upload it. The agent does the gdrive API call via the MCP tool.envless backup --output /tmp/envless-$(date -u +%Y%m%d).tar.gzThen in your agent:
Upload
/tmp/envless-20260604.tar.gzto my “Envless Backups” folder on Google Drive using the gdrive MCP tool.
The agent calls the gdrive.upload_file tool; the file lands in
Drive. The tarball contains only encrypted artefacts, so a Drive leak
is bounded by the cryptographic strength of age + sops.
rclone (any cloud)
rclone is the lingua franca of cloud sync. One remote per provider,
one rcat invocation per backup:
# One-time: configure a "gdrive" remote.rclone config
# Daily backup, streamed straight into Drive — no local tempfile.envless backup | rclone rcat gdrive:envless-backups/$(date -u +%Y%m%d).tar.gzSwap gdrive: for s3:, b2:, azureblob:, etc., per the rclone
backends list.
restic
restic adds deduplication, encryption, and snapshot management on top of any storage backend. Best fit if you back up many repos.
restic -r s3:s3.amazonaws.com/my-restic-repo init # one timeenvless backup --output /tmp/envless.tar.gzrestic -r s3:s3.amazonaws.com/my-restic-repo backup /tmp/envless.tar.gzrm /tmp/envless.tar.gzRestic’s own at-rest encryption means even an S3-bucket leak does not expose your tarball; it is defense-in-depth on top of envless’s encrypted artefacts.
Git LFS (small teams)
For three-person teams without external object storage, Git LFS in a private repo is fine:
git lfs installgit lfs track "envless-backups/*.tar.gz"git add .gitattributesenvless backup --output envless-backups/$(date -u +%Y%m%d).tar.gzgit add envless-backups/ && git commit -m "envless backup" && git pushThis is the lowest-friction option but only works if your Git host gives you enough LFS bandwidth/quota.
Identity key backup (separate workflow)
The identity key is what makes the encrypted artefacts useful. Losing it is one of the disaster-recovery scenarios above. Backing it up safely is its own discipline.
Acceptable destinations
- Password manager attachment. 1Password / Bitwarden /
pass— one item per identity, with the tarball or just theidentity.keyitself as the attachment. - GPG-encrypted file uploaded anywhere. The GPG layer is what makes the destination irrelevant.
- Encrypted local backup sitting on an FDE’d volume (FileVault, LUKS, BitLocker).
Unacceptable destinations
- Plain Google Drive / iCloud Drive / Dropbox.
- Email or Slack / Teams / Discord attachments.
- Unencrypted external drives.
- A second laptop without FDE.
Producing an identity-included backup
# Interactive: prompts for confirmation after printing a warning.envless backup --include-identity --output backup-with-key.tar.gz
# Non-interactive (script / cron): requires explicit --yes.envless backup --include-identity --yes --output backup-with-key.tar.gzWithout --yes, a non-TTY run exits with code 2 and a message
telling you to pass --yes if you really mean it. This is the
guard-rail against accidentally including the key in an automated
pipeline.
Recommended: GPG-wrap before upload
The cleanest pattern for backing up the identity is to stream the tarball through GPG symmetric encryption and only ever store the encrypted output:
envless backup --include-identity --yes --output - \ | gpg --symmetric --cipher-algo AES256 \ --output backup-$(date -u +%Y%m%d).tar.gz.gpg
# Restore (interactive — gpg will prompt for the passphrase).gpg --decrypt backup-20260604.tar.gz.gpg | tar -xz -C /target/repoThe passphrase lives in your password manager. The .tar.gz.gpg file
can ride along on any cloud — it is opaque without the passphrase.
Restore
A backup is just a tarball; restore is just tar -x.
# Untar into the repo root (or a fresh checkout).tar -xzf backup-20260604.tar.gz -C /path/to/repo
# Verify the manifest first if you want.tar -xzOf backup-20260604.tar.gz MANIFEST.json | jq .If MANIFEST.json reports "includes_identity": false, the restore
gives you back the encrypted artefacts only — you still need an age
identity with read access to actually decrypt. This is the right
shape for “back up the repo state without leaking key material.”
If the manifest reports "includes_identity": true, the restore puts
the age secret key back at .envless/identity.key. Chmod it to 0600
after restoring (Git doesn’t track Unix modes; tar will preserve
modes only if your tar honoured them on extract):
chmod 0600 .envless/identity.keyScheduled backup recipes
For daily snapshots, wrap envless backup in a cron job or systemd
timer.
Plain cron
# Daily UTC backup with date-stamped filename. Keeps everything;# pair with a `find -mtime +30 -delete` if you want rotation.0 3 * * * cd /path/to/repo && envless backup \ --output /backups/envless-$(date -u +\%Y\%m\%d).tar.gzcron + rclone (off-host)
0 3 * * * cd /path/to/repo && envless backup \ | rclone rcat gdrive:envless-backups/$(date -u +\%Y\%m\%d).tar.gzAgent / MCP-driven schedule
If your agent client supports scheduled jobs (Claude Code’s schedule
skill, Cursor’s task runner, etc.), the recipe is two steps:
- Run
envless backup --output /tmp/envless-$(date -u +%Y%m%d).tar.gz. - Call the Google Drive MCP
upload_filetool with the tarball path and your target folder.
The agent transcript becomes the audit trail. Restrict the MCP server’s folder scope so a misconfigured upload can’t land in a shared drive.