Skip to content

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

Terminal window
# 1. Clone the repo.
git clone git@github.com:org/repo.git
cd 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.

Terminal window
# 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"
done
done
# 3. Commit.
git checkout -b onboard-alice
git add .envless/recipients secrets/
git commit -m "onboard alice: add recipient + re-encrypt envs"
git push -u origin onboard-alice
gh pr create --fill

The 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.

Terminal window
# On the CI runner (or one-shot, key stored in GH Actions secret):
age-keygen -o /tmp/ci-identity.key
PUBKEY=$(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/recipients PR 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.

# Humans
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # alice
age1z6f0ldygfp22k0w50zss5pl4kxvkzlulhpqcmm8m65rt6q08vrnsq8ec6t # bob
# CI
age1mh6gh39mhqaeu2cysr0gwz2ts83krppyzj8nrz86g2yax7gx7scqgca2x9 # gh-actions-deploy

Comments 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:

  1. Two repos. One per trust tier. Crude but secure.
  2. Wait for v0.1’s .envless/team.yaml which introduces per-env recipient roles.

Programmatic listing

Terminal window
# Stable, scriptable list of pubkeys
grep -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.

  1. On a fresh checkout, run envless init. A new identity is generated.
  2. Get a teammate (or any existing recipient) to add your new pubkey to .envless/recipients and 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.

  1. Rotate every secret value at the upstream provider. The leaked key can decrypt all current and historical encrypted files in Git. See Security → Rotation.
  2. Remove the leaked pubkey from .envless/recipients.
  3. Generate a new identity (envless init after rm .envless/identity.key) and add it to recipients.
  4. Re-encrypt every env with the new recipient set.
  5. 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:

  1. Recover secret values from their upstream sources (provider dashboards, prior .env backups, the team password manager, etc.).
  2. Initialize a fresh envless state on a clone:
    Terminal window
    rm -rf .envless secrets
    envless init
    # then `envless set` each recovered value
  3. 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:

  1. Identify the most recent clone.
  2. git push --mirror to a new remote.
  3. 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.

  1. git log -p secrets/<env>.env.enc — find the last known-good commit.
  2. git checkout <good-sha> -- secrets/<env>.env.enc.
  3. 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.key in an offline password manager (1Password, Bitwarden) per identity holder.
  • Periodic git bundle create of 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:

  1. Generate a CI bot identity (an age keypair).
  2. Add its pubkey to .envless/recipients and re-encrypt.
  3. Store the secret key as a single CI-provider secret (AGE_IDENTITY or similar).
  4. In the workflow, write that secret to a temp file and point envless at it.

GitHub Actions

.github/workflows/deploy.yml
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 deploy

The 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 variable

Arbitrary Docker-based pipeline

Same shape, in a Dockerfile:

FROM alpine:3.20 AS build
RUN 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/sops
RUN 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 /src
COPY . .
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.sh

Build with docker build --secret id=age_identity,src=$HOME/.config/envless/ci.key ..

Hardening tips

  • One bot identity per pipeline — separate keys for staging and prod. Lets you revoke independently.
  • No envless get in CI. Use envless exec so plaintext only exists in the child process’s env, never in CI logs.
  • umask 077 before writing the identity file — defaults vary across runners; this is one line of insurance.
  • Mask the AGE_IDENTITY secret. 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-github plugin — 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 into gh 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

Terminal window
# macOS — installs a LaunchAgent at ~/Library/LaunchAgents/.
envless daemon install
# Linux — installs a user systemd unit at ~/.config/systemd/user/.
envless daemon install

Both paths run the daemon under your user account (not root) and start it immediately. Status:

Terminal window
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

Terminal window
envless daemon uninstall

This 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 mcp auto-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

Terminal window
# 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.gz

Output (on stderr — stdout is reserved for streaming the tarball when no --output is given):

BACKUP out=backup-20260604.tar.gz identity=excluded

The tarball contains:

MANIFEST.json # archive metadata (see schema below)
.envless/recipients # the public-key access list
secrets/dev.env.enc # every encrypted env, one file each
secrets/prod.env.enc

The 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:

Terminal window
envless backup | rclone rcat gdrive:envless-backups/$(date -u +%Y%m%d).tar.gz
envless backup | aws s3 cp - s3://my-bucket/envless-$(date -u +%Y%m%d).tar.gz
envless backup | gpg --symmetric --cipher-algo AES256 > backup.tar.gz.gpg

Manifest 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.key is in the tarball. The CLI sets this from the --include-identity flag.
  • envs: lexicographically sorted env names found under secrets/.
  • 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:

Terminal window
# 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.gz

Then in your agent:

Upload /tmp/envless-20260604.tar.gz to 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:

Terminal window
# 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.gz

Swap 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.

Terminal window
restic -r s3:s3.amazonaws.com/my-restic-repo init # one time
envless backup --output /tmp/envless.tar.gz
restic -r s3:s3.amazonaws.com/my-restic-repo backup /tmp/envless.tar.gz
rm /tmp/envless.tar.gz

Restic’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:

Terminal window
git lfs install
git lfs track "envless-backups/*.tar.gz"
git add .gitattributes
envless backup --output envless-backups/$(date -u +%Y%m%d).tar.gz
git add envless-backups/ && git commit -m "envless backup" && git push

This 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 the identity.key itself 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

Terminal window
# 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.gz

Without --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.

The cleanest pattern for backing up the identity is to stream the tarball through GPG symmetric encryption and only ever store the encrypted output:

Terminal window
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/repo

The 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.

Terminal window
# 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):

Terminal window
chmod 0600 .envless/identity.key

Scheduled 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.gz

cron + rclone (off-host)

0 3 * * * cd /path/to/repo && envless backup \
| rclone rcat gdrive:envless-backups/$(date -u +\%Y\%m\%d).tar.gz

Agent / 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:

  1. Run envless backup --output /tmp/envless-$(date -u +%Y%m%d).tar.gz.
  2. Call the Google Drive MCP upload_file tool 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.