]`
```bash
envpilot request
envpilot request --project api
```
- Only assigned developers can submit requests — owners, project managers, and team leads create variables directly.
- Environment choices are limited to the developer's assigned environment scope.
- An owner, project manager, or team lead must approve the request before the variable is created.
### `envpilot requests`
List variable requests, or approve/reject/cancel them without leaving the terminal.
**Arguments** — `[list] [--project ] [--status ] [--json] | approve [--value |--value-stdin] [--reason ] | reject [--reason ] | cancel `
```bash
envpilot requests
envpilot requests --status pending
envpilot requests approve
envpilot requests approve --value sk_live_…
envpilot requests reject --reason "use the shared key"
envpilot requests cancel
```
- Reviewers (owner, assigned project manager/team lead) see every request; developers see only their own.
- --status/--json apply to listing only; --value/--value-stdin/--reason apply to review subcommands only.
- Approving a machine (valueless) request prompts MASKED for the value; --value-stdin reads it from stdin for CI (keeps it out of argv), --value is a last resort that lands in shell history.
- Get the \ from the ID column of `envpilot requests`.
### `envpilot secrets`
Change one secret without pull/edit/push. Two-step by default: key first, value prompted MASKED (never in shell history).
**Arguments** — `set [|] [-e ] [-p ] [-d ] [--sensitive] [--all-envs] | rm [-e ] [-p ] [--yes]`
```bash
envpilot secrets set
envpilot secrets set STRIPE_SECRET_KEY --env production
envpilot secrets set API_URL=https://api.example.com
envpilot secrets rm OLD_FLAG --env staging --yes
```
- Interactive by default: the key is validated first, then the value is prompted masked so it never lands in shell history — KEY=VALUE inline is for CI and prints a history warning.
- Role-aware: direct-write roles set immediately; request-only roles are offered the request workflow instead (a reviewer approves with `requests approve`).
- Plan limits are enforced server-side and reported readably; check `envpilot usage` for your tier.
- set upserts one key in one environment (merge); updating a value shared across environments requires confirmation (--all-envs non-interactively).
- rm on a single-environment secret moves it to trash (recoverable from the dashboard); on a shared secret it only removes THIS environment — the value stays live in the others.
- `envpilot var …` still works as an alias.
### `envpilot run`
Inject project secrets into a child process without writing a .env file (envpilot run -- bun dev).
**Arguments** — `[--env ] [--project ] [--keep-existing] [--print] -- [args...]`
```bash
envpilot run -- bun dev
envpilot run --env production -- node server.js
envpilot run --project api -- pnpm test
envpilot run --print
envpilot run --keep-existing -- bun dev
```
- Use `--` to separate envpilot flags from the command to execute.
- Secrets override existing shell vars by default; pass --keep-existing to flip.
- On Windows, the command is run through the shell so .cmd / .bat files resolve.
- Signals (SIGINT, SIGTERM, SIGHUP, SIGQUIT) are forwarded to the child process.
- Use --print to inspect what would be injected without executing anything.
### `envpilot list`
List organizations, projects, variables, or linked projects from the terminal.
**Arguments** — `[resource]`
```bash
envpilot list
envpilot list projects
envpilot list variables
```
- Default resource is `projects`.
- Use `linked` to inspect local `.envpilot` project links.
### `envpilot list organizations`
List organizations available to the current user.
```bash
envpilot list organizations
envpilot list orgs --json
```
- Useful for discovering organization IDs and roles.
### `envpilot list projects`
Browse projects in the active organization.
**Arguments** — `[--organization ] [--json]`
```bash
envpilot list projects
envpilot list projects --json
```
- Shows project roles when available.
- Useful for selecting project IDs for automation and scripts.
### `envpilot list variables`
Inspect variables for a project with environment and tag filtering.
**Arguments** — `[--project ] [--env ] [--tag ]`
```bash
envpilot list variables
envpilot list variables --env production --show-values
```
- Values are masked by default.
- Designed to mirror the web app’s searchable variable surface.
### `envpilot list linked`
Show projects linked in the current directory.
```bash
envpilot list linked
```
- Shows the active linked project and environment mapping.
### `envpilot switch`
Switch the active organization, project, environment, or linked project.
**Arguments** — `[--organization ] [--project ] [--env ] [--active ]`
```bash
envpilot switch --env production
envpilot switch --active api
```
- Updates local CLI state without rewriting the whole project config.
- Supports both linked projects and remote project lookup.
### `envpilot usage`
Inspect current plan usage and feature availability for the active organization.
**Arguments** — `[--organization ] [--json]`
```bash
envpilot usage
envpilot usage --json
```
- Shows project, member, and variable limits.
- Useful for CLI feature-gate troubleshooting.
### `envpilot whoami`
Show the authenticated user, API target, and current active CLI context.
```bash
envpilot whoami
```
- Validates the current access token against the website.
- Useful for debugging stale auth or wrong API URL targets.
### `envpilot accounts`
List authenticated accounts and switch or remove them without logging out.
**Arguments** — `[list|switch |remove ]`
```bash
envpilot accounts
envpilot accounts switch you@example.com
envpilot accounts remove you@example.com
```
- Identifiers can be an account id or the account's email (case-insensitive).
- Switching accounts does not log anyone out; use `envpilot logout` to remove the active session.
### `envpilot files`
Manage secret files — keystores, SSH keys, certificates, and service-account JSON — that cannot live in a .env.
**Arguments** — `[list|status|pull|add |get |rm ]`
```bash
envpilot files list
envpilot files status
envpilot files pull
envpilot files add ./upload.jks --path android/app/upload.jks -e production
```
- `list` and `status` are metadata-only: nothing is decrypted and no download is recorded.
- `pull` refuses to overwrite a local file that differs from the server unless you pass --force.
- Pulled paths are added to .gitignore before the files are written.
- Every download of file contents is audited.
### `envpilot config`
Inspect or update local CLI configuration such as the active API URL.
**Arguments** — `[list|get|set|path|reset]`
```bash
envpilot config
envpilot config path
```
- Useful for local development against non-production API URLs.
- Shows both global and project-level config paths.
### `envpilot logout`
Revoke the current CLI session and clear local auth state.
```bash
envpilot logout
```
- Best way to reset a stale CLI session cleanly.
- Clears local tokens even if the revoke call fails.
### `envpilot unlink`
Remove a linked project from the current directory without deleting local env files.
**Arguments** — `[project] [--force]`
```bash
envpilot unlink
envpilot unlink api --force
```
- Updates `.envpilot` and active-project state.
- Leaves existing `.env` files on disk.
{/* generated:cli-commands end */}
========================================================================
CLI IN CI & TROUBLESHOOTING
========================================================================
Source: https://docs.envpilot.dev/cli/ci
# CLI in CI & troubleshooting
## Should you use the CLI in CI at all?
Often not. The CLI authenticates as a **person** through a browser device flow, which no CI runner can complete. For pipelines, prefer:
| Situation | Use |
| ------------------------------- | ----------------------------------------- |
| GitHub Actions | [The Envpilot Action](/action/overview) |
| Any other CI, or your own tool | [REST API](/api/overview) with an API key |
| A local script you run yourself | The CLI, non-interactively as below |
## Non-interactive rules
Every command that would prompt takes flags to answer instead:
```bash
envpilot pull --env production --force --quiet
envpilot push --env staging --merge --force
envpilot secrets rm OLD_FLAG -e staging --yes
envpilot files pull -e production --force
envpilot run --env production --quiet --no-cache -- ./deploy.sh
```
Add `--json` where it is offered (`list`, `usage`, `requests`) and parse that instead of scraping human output, which is free to change.
## Keeping secrets out of argv
Anything on a command line is visible in `ps`, in shell history, and often in CI logs.
```bash
# Good — the value arrives on stdin
printf %s "$SECRET" | envpilot requests approve --value-stdin
# Avoid — lands in history and argv
envpilot secrets set API_KEY=sk_live_…
```
`--value` is deliberately rejected in interactive sessions for the same reason.
## Exit codes
`envpilot run` exits with **the child process's exit code**, so it composes cleanly:
```bash
envpilot run -- npm test || exit 1
```
Other commands exit non-zero on failure — including the deliberate refusals, such as `files pull` declining to overwrite a locally modified file.
## Failures worth recognising
| What you see | What it means |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `Not authenticated` | No session in this environment. Run `envpilot login`, or use an API key for CI. |
| 401 on the first call after weeks | The session was revoked — by an admin, by a sign-out, or by [Security Hold](/platform/rbac#security-hold). |
| A hard stop asking you to upgrade | Your CLI is below the server's minimum supported version. Upgrade; there is no override. |
| `⚠ N variable(s) were NOT written (access denied)` | Your role holds no write grant for those keys. This is not a bug — see [Pull & push](/cli/pull-push). |
| `Refusing to overwrite. Re-run with --force` | A local secret file differs from the server. Inspect it before forcing. |
| `Limit reached (3/3)` | A plan limit. `envpilot usage --json` shows every limit and your current count. |
| A rate-limit message with a retry window | Back off for the stated number of seconds. See [Rate limits](/limits/rate-limits). |
## Diagnostics
```bash
envpilot whoami # identity, API target, active context
envpilot usage --json # plan limits vs current usage
envpilot config path # where local state lives
envpilot list linked # what this directory is bound to
```
If the CLI seems to be acting on the wrong project, it is almost always the active link — `envpilot list linked` then `envpilot switch --active `.
## Limits
- No headless login. A device flow needs a browser.
- The version check fails **open** on network errors, so an outage never blocks your commands; it also means a stale CLI can go unflagged offline.
- Machine-readable output exists for some commands, not all; where `--json` is absent, treat output as human-facing and subject to change.
========================================================================
VS CODE OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/extension/overview
# VS Code overview
Envpilot v1.17.1 for VS Code and Cursor: real-time variable sync, secret files materialised into the workspace, commit and clipboard protection, and editor intelligence over your `.env` files.
Requires VS Code 1.85+ or the Cursor equivalent.
## Install
Search for **Envpilot** in the Extensions sidebar, or install from the [Marketplace](https://marketplace.visualstudio.com/items?itemName=envpilot.envpilot). The same extension works in Cursor.
## Sign in
Several accounts can be signed in at once: **Envpilot: Switch Account** moves between them, **Envpilot: Sign Out** ends the active one, and **Envpilot: Sign Out of All Accounts** clears every one.
## What you get
| Area | What happens |
| ------------------------------------ | --------------------------------------------------------------------- |
| Sidebar | Linked projects, environments and variables in the Activity Bar |
| Status bar | Current project, environment, and sync state at a glance |
| Dashboard panel | **Envpilot: Open Dashboard Panel** — an in-editor view of the project |
| [Sync](/extension/sync) | `.env` files and secret files written and kept current |
| [Protection](/extension/protection) | Commit guard, clipboard guard, value cloaking |
| [Editor features](/extension/editor) | CodeLens, autocomplete, hover, request-a-variable |
## Trust matters
In VS Code **Restricted Mode** the extension never writes secrets: sync, pull and link are disabled until you trust the workspace. Cleanup still runs, so previously synced `.env` files are removed on close and after crashes. Trust is re-checked mid-sync, not just at the start — a window that drops to Restricted must never end up holding a plaintext keystore.
## Limits
- **VS Code and Cursor only.** JetBrains IDEs are covered by the [JetBrains plugin](/jetbrains/overview); there is no Neovim or Zed build.
- **Uploading secret files is not possible from the editor** — the extension writes them, the CLI and dashboard manage them.
- Everything is bounded by your role. Revealing values, for instance, requires a capability an owner grants; see [Protection](/extension/protection).
- The extension enforces a minimum supported version against the server and fails open on network errors.
## Next
- [Linking & sync](/extension/sync)
- [Commands](/extension/commands) · [Settings](/extension/settings)
========================================================================
LINKING & SYNC
========================================================================
Source: https://docs.envpilot.dev/extension/sync
# Linking & sync
## Link a project
**Envpilot: Unlink Project** removes the link. **Envpilot: Pull Variables** syncs on demand; **Envpilot: Refresh** re-reads state without pulling.
## What sync does
- On workspace open (`envpilot.autoSync`), the latest variables are pulled and written.
- A live connection keeps values current while the window is open; a background check (`envpilot.syncInterval`, default 300 s) catches permission changes.
- Real-time subscriptions pause after the window has been unfocused for `envpilot.idlePauseMinutes` (default 10) and resume the moment focus returns. Set it to `0` to never pause.
- When access is revoked, the extension deletes the synced `.env` files it wrote (`envpilot.preventCopyOnRevoke`).
## Secret files in the workspace
Secret files sync alongside variables and receive the same treatment as a managed `.env`: recorded in the manifest, protected from clipboard copy, watched for unauthorised edits, and written with their own mode (`0600` or `0400`) rather than a `.env`'s.
Two behaviours to know:
- **One environment per directory.** A file has exactly one path, so a directory linked to several environments materialises the **first** one. A dev and a prod `google-services.json` cannot both land in the same folder.
- **Local edits are not silently overwritten.** A locally modified secret file is reported as a conflict and left alone. The exception is the edit watcher: if you hand-edit a managed secret file, the extension reverts that one file, because reverting an unauthorised edit is the whole point of the watcher.
## Several directories, one workspace
Link different directories to different projects or environments; each syncs independently.
```
my-monorepo/
├── apps/api/.env ← production
├── apps/web/.env.local ← development
└── packages/sdk/.env ← staging
```
Manage them with **Envpilot: Add Directory** and **Envpilot: Remove Directory**, and pick environments per link with **Envpilot: Select Environments**.
## When a file already exists
`envpilot.defaultConflictResolution` decides what happens when a target file is already there:
| Value | Behaviour |
| ----------- | ------------------------------------- |
| `prompt` | Ask every time (default) |
| `overwrite` | Replace the existing file |
| `backup` | Back it up, then replace |
| `merge` | Merge with the existing file |
| `skip` | Leave the conflicting directory alone |
## Cleaning up on close
Synced files can be removed when the workspace closes, so a laptop left open in a café is not a `.env` archive. The per-project default and per-member override (`vscodeAutoUnsyncOnClose`) are Pro-gated (`vscode_unsync_customization`); cleanup after a crash runs regardless of trust state.
## Limits
- Sync writes what your role can read. Nothing indicates that other variables exist.
- Restricted Mode disables writing entirely — trust the workspace first.
- `envpilot.syncInterval` is a permission check, not a value poll; values arrive over the live connection.
- One target file per linked directory.
## Next
- [Protection](/extension/protection)
- [Settings reference](/extension/settings)
========================================================================
PROTECTION
========================================================================
Source: https://docs.envpilot.dev/extension/protection
# Protection
A synced `.env` is plaintext on your disk. These four guards exist because that plaintext has more ways to escape than most people expect.
## Commit guard
Dual-layer protection against committing a `.env`:
1. **Staging guard** — VS Code warns when you stage a managed `.env` and offers to unstage it.
2. **Pre-commit hook** — a git hook that blocks the commit at the git level, so it also catches commits made outside the editor.
Both are on by default (`envpilot.commitGuard.enabled`, `envpilot.commitGuard.autoInstallHook`). Manage the hook by hand with **Envpilot: Install Commit Guard Hook** and **Envpilot: Remove Commit Guard Hook**.
## Clipboard guard
Copy and cut are blocked inside Envpilot-managed files, because "copy the whole `.env` into a chat window" is how most secrets actually travel.
| `envpilot.clipboardGuard.scope` | Behaviour |
| ------------------------------- | ------------------------------------------------------------- |
| `all-managed` (default) | Block copy/cut in every managed `.env`, whatever your role |
| `readonly-roles` | Block only in read-only files (viewer and request-only roles) |
| `off` | Never block |
`Cmd+C` / `Cmd+X` inside a protected file trigger an explanation instead of a copy. Secret files are always treated as strict read-only, because the dashboard and CLI are their write path.
## Value cloaking
`envpilot.cloakValues` (on by default) masks values in managed `.env` files with a fixed-length `••••••` decoration. The mask is a constant width, so it does not leak the real value's length, and the file on disk is untouched — sync, diff, and saving all see the real text.
- **Envpilot: Toggle Value Cloaking** turns masking off or on.
- **Envpilot: Reveal Values for 30 Seconds** unmasks temporarily and re-masks itself.
Both the reveal command and the _unmasking_ direction of the toggle are role-gated on the `project.secrets.reveal` capability. Re-masking is always allowed — someone who loses the capability while cloaking is off must still be able to hide values again.
## Unauthorised-edit reversion
Managed files are watched. Hand-editing a synced `.env` or a secret file triggers a warning and a revert to the server's version, restoring the file's own mode — a keystore goes back to `0600`/`0400`, not to a `.env`'s more permissive bits.
The write path is the dashboard, the CLI, or a [request](/platform/requests). Editing the file on disk is not a write path, and pretending otherwise would mean silent divergence between what you run and what your team ships.
## Revocation cleanup
When access is revoked, the extension deletes the synced files it wrote (`envpilot.preventCopyOnRevoke`, on by default). Combined with [Security Hold](/platform/rbac#security-hold), that turns "revoke this person" into "their disk is clean within seconds", not "their disk is clean at their next sign-in".
## Limits
- Guards apply to files **Envpilot manages**. A `.env` you created by hand is not tracked, and nothing about it is protected.
- The pre-commit hook is a local git hook — a fresh clone has no hook until the extension or `envpilot sync` installs one.
- Clipboard guard covers the editor's copy and cut. It cannot stop a screenshot, a terminal `cat`, or a file manager.
- None of this survives the file leaving your machine. If a secret is exposed, [rotate it](/platform/rotation).
## Next
- [Editor features](/extension/editor)
- [Security](/platform/security)
========================================================================
EDITOR FEATURES
========================================================================
Source: https://docs.envpilot.dev/extension/editor
# Editor features
Beyond syncing files, the extension knows which variables your project actually has — and uses that where you are typing.
## CodeLens on `.env` files
`envpilot.enableCodeLens` (on) annotates managed `.env` files with their sync status and quick actions, so you can tell a synced file from a hand-written one without reading its contents.
## Autocomplete for variable names
`envpilot.autocomplete.enable` (on) suggests variable names from your linked project when you type an environment-variable reference — `process.env.`, `os.getenv(`, and the equivalents in other languages.
Suggestions come from the project's **keys**. Values are never offered as completions.
## Hover
`envpilot.hover.enable` (on) shows an Envpilot hover on recognised environment-variable references: which environments define the key, and a **masked** preview. The hover carries a **Reveal value** link gated on the same `project.secrets.reveal` capability as everything else — a role that cannot reveal values cannot reveal them one hover at a time either.
## Request a variable
When you need a key you cannot see, run **Envpilot: Request Variable**. It files the same request the CLI and dashboard file, and a reviewer approves it and supplies the value. See [Requests & approvals](/platform/requests).
## Status and dashboard
- **Envpilot: Show Status** — a summary of the current session, links and sync state.
- **Envpilot: Open Dashboard Panel** — an in-editor panel for the linked project.
- **Envpilot: Open Dashboard** — the same project on the web.
## Limits
- Autocomplete and hover only know about **linked** projects. An unlinked workspace gets neither.
- Hover recognises common reference patterns; an exotic accessor may not be detected.
- Reveal is role-gated at the command, not just in the UI — hiding the palette entry alone would be bypassable by keybinding.
- These features read project metadata, not secret values; a hover reveal is what triggers a value read.
## Next
- [Commands](/extension/commands) · [Settings](/extension/settings)
- [Troubleshooting](/extension/troubleshooting)
========================================================================
COMMANDS
========================================================================
Source: https://docs.envpilot.dev/extension/commands
# Commands
Everything below is available from the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) by typing **Envpilot**.
{/* generated:extension-commands start */}
| Command palette entry | Command id |
| -------------------------------------- | ----------------------------- |
| Envpilot: Sign In | `envpilot.signIn` |
| Envpilot: Sign Out | `envpilot.signOut` |
| Envpilot: Link Project | `envpilot.linkProject` |
| Envpilot: Unlink Project | `envpilot.unlinkProject` |
| Envpilot: Pull Variables | `envpilot.pullVariables` |
| Envpilot: Refresh | `envpilot.refresh` |
| Envpilot: Open Dashboard | `envpilot.openDashboard` |
| Envpilot: Show Status | `envpilot.showStatus` |
| Envpilot: Add Directory | `envpilot.addDirectory` |
| Envpilot: Remove Directory | `envpilot.removeDirectory` |
| Envpilot: Select Environments | `envpilot.selectEnvironments` |
| Envpilot: Request Variable | `envpilot.requestVariable` |
| Envpilot: Install Commit Guard Hook | `envpilot.installCommitGuard` |
| Envpilot: Remove Commit Guard Hook | `envpilot.removeCommitGuard` |
| Envpilot: Open Dashboard Panel | `envpilot.openDashboardPanel` |
| Envpilot: Switch Account | `envpilot.switchAccount` |
| Envpilot: Sign Out of All Accounts | `envpilot.signOutAll` |
| Envpilot: Toggle Value Cloaking | `envpilot.toggleCloaking` |
| Envpilot: Reveal Values for 30 Seconds | `envpilot.revealValues` |
{/* generated:extension-commands end */}
## Keybindings
`Cmd+C` / `Ctrl+C` and `Cmd+X` / `Ctrl+X` are intercepted inside Envpilot-managed files when the [clipboard guard](/extension/protection) is active. The binding is scoped by a context key, so it never affects any other file you have open.
## Limits
- There is no command to upload a [secret file](/platform/secret-files) — the extension writes them, the CLI and dashboard manage them.
- Commands that reveal values check your role at invocation, so a keybinding or URI cannot bypass the palette's visibility rules.
========================================================================
SETTINGS
========================================================================
Source: https://docs.envpilot.dev/extension/settings
# Settings
Configure under **Settings → Extensions → Envpilot** (`Cmd+,`), or in `settings.json`.
{/* generated:extension-settings start */}
| Setting | Type | Default | What it does |
| -------------------------------------- | ------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `envpilot.serverUrl` | string | — | The URL of the Envpilot server (auto-configured at build time) |
| `envpilot.autoSync` | boolean | `true` | Automatically sync variables when workspace opens |
| `envpilot.syncInterval` | number | `300` | Interval (in seconds) for checking permission changes |
| `envpilot.targetFile` | string | `".env.local"` | Default target file for synced variables |
| `envpilot.environment` | string | `"development"` | Default environment for synced variables. Options: `development`, `staging`, `production`. |
| `envpilot.preventCopyOnRevoke` | boolean | `true` | Delete synced .env files when permissions are revoked |
| `envpilot.defaultConflictResolution` | string | `"prompt"` | Default action when existing .env files are found. Options: `prompt`, `overwrite`, `backup`, `merge`, `skip`. |
| `envpilot.convexUrl` | string | — | Convex deployment URL for real-time sync (auto-detected from server if empty) |
| `envpilot.enableCodeLens` | boolean | `true` | Show CodeLens annotations above .env files with sync status and actions |
| `envpilot.commitGuard.enabled` | boolean | `true` | Enable dual-layer .env commit protection (VS Code staging guard + pre-commit hook) |
| `envpilot.commitGuard.autoInstallHook` | boolean | `true` | Automatically install a pre-commit hook to block .env file commits |
| `envpilot.clipboardGuard.scope` | string | `"all-managed"` | Which Envpilot-managed .env files block clipboard copy/cut. Options: `all-managed`, `readonly-roles`, `off`. |
| `envpilot.cloakValues` | boolean | `true` | Visually mask variable values in Envpilot-managed .env files (the file content on disk is unchanged). Masking uses editor decorations, so values may still be visible in surfaces VS Code renders directly: the minimap (with editor.minimap.renderCharacters on), peek views, and diff editors. |
| `envpilot.autocomplete.enable` | boolean | `true` | Suggest variable names from your linked Envpilot project when typing environment variable references (process.env., os.getenv, etc.) |
| `envpilot.hover.enable` | boolean | `true` | Show a masked Envpilot hover on recognized environment variable references, with a role-checked option to reveal the value |
| `envpilot.idlePauseMinutes` | number | `10` | Minutes the window must stay unfocused before real-time sync (Convex WebSocket subscriptions) is paused. Set to 0 to disable idle-pausing and keep real-time sync running regardless of focus. |
{/* generated:extension-settings end */}
## Notes on a few of them
- **`envpilot.serverUrl` / `envpilot.convexUrl`** are configured at build time and should be left alone unless you are running against a non-production instance.
- **`envpilot.cloakValues`** masks with editor decorations. See the caveat in [Protection](/extension/protection).
- **`envpilot.idlePauseMinutes`** trades a little freshness for a lot of idle connection cost. `0` disables pausing.
- **Unsync-on-close customization** is Pro-gated (`vscode_unsync_customization`); on Free the project default applies.
## Limits
- Settings marked `machine` scope cannot be set per workspace — that is deliberate for anything that changes where credentials are sent.
- A setting change takes effect on the next sync unless stated otherwise; use **Envpilot: Refresh** to apply immediately.
========================================================================
TROUBLESHOOTING
========================================================================
Source: https://docs.envpilot.dev/extension/troubleshooting
# Troubleshooting
## Nothing syncs and no error appears
Check workspace trust. In **Restricted Mode** the extension never writes secrets — sync, pull and link are all disabled until you trust the workspace. Cleanup still runs, which is why previously synced files may vanish while nothing new arrives.
## Variables I expect are missing
You receive what your role can read, and nothing signals that more exists. Confirm with **Envpilot: Show Status** which project and environment the directory is linked to, then check [Roles & permissions](/platform/rbac). If you genuinely need a key you cannot see, run **Envpilot: Request Variable**.
## My `.env` keeps reverting
That is the edit watcher. Managed files are restored from the server when hand-edited — the write path is the dashboard, the CLI, or an approved request. If you meant to change the value, [`envpilot secrets set`](/cli/secrets) does it in one command.
## Copy does nothing in a `.env`
The [clipboard guard](/extension/protection) is blocking it. Change `envpilot.clipboardGuard.scope` if your team wants a looser policy — `readonly-roles` limits blocking to read-only files, `off` disables it.
## Values show as `••••••`
Cloaking. **Envpilot: Reveal Values for 30 Seconds**, or toggle it off — both need the reveal capability. If you get "your role does not allow unmasking secret values", ask an organization owner; it is a role capability, not a setting.
## Secret files did not appear
- Only the **first** linked environment's files are materialised into a directory.
- A locally modified copy is treated as a conflict and left alone, so an old hand-placed keystore blocks the new one. Move it aside and sync again.
- Uploading is not possible from the editor — use [`envpilot files add`](/cli/files) or the dashboard.
## Suddenly signed out, files deleted
Access was revoked, or a [Security Hold](/platform/rbac#security-hold) was applied. Both are immediate and both trigger local cleanup by design. Ask an organization owner.
## An upgrade prompt blocks everything
Your extension version is below the server's supported minimum. Update from the Marketplace; there is no override, because the older build calls contracts that no longer exist.
## Still stuck
- **Envpilot: Show Status** — session, links, sync state
- **Envpilot: Refresh** — re-read state without pulling
- **Envpilot: Sign Out of All Accounts**, then sign in again — clears a wedged session
- The Output panel's Envpilot channel carries the extension's own log
If a problem survives that, the CLI is a useful second opinion: `envpilot whoami` and `envpilot list linked` answer the same questions from outside the editor.
========================================================================
JETBRAINS OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/jetbrains/overview
# JetBrains overview
Envpilot v0.1.7 for JetBrains IDEs: pull variables and secret files into your project, keep them current over a live connection, and keep them from leaking through the editor.
Works on any IntelliJ-platform IDE 2025.1 or newer, with no upper bound: IntelliJ IDEA, Android Studio, PyCharm, GoLand, WebStorm, Rider.
## Install
Search for **Envpilot** under **Settings → Plugins → Marketplace**, or press install right here:
Machines without Marketplace access: download the signed zip from the [versions page](https://plugins.jetbrains.com/plugin/33946-envpilot/versions) and install it with **Settings → Plugins → ⚙ → Install Plugin from Disk**.
## Sign in
Multiple accounts are fine. **Switch Account**, **Sign Out**, and **Sign Out All Accounts** live in the same Tools menu, and each account keeps its own project links and tokens.
## What you get
| Area | What happens |
| ----------------------------------- | ------------------------------------------------------------------ |
| Tool window | Browse organizations, projects and environments, link directories |
| Status bar widget | Connection and sync state at a glance |
| [Sync](/jetbrains/sync) | Variables written to the target `.env`, secret files materialized |
| [Protection](/jetbrains/protection) | Value cloaking, clipboard guard, drift detection, commit guard |
| Editor | Autocomplete and gutter markers in `.env` files |
| Requests | Ask for a variable through the [approval flow](/platform/requests) |
## Limits
- Requires an Envpilot account. [Sign up](https://www.envpilot.dev/sign-up) free.
- Requires the **JetBrains IDE Plugin** feature on your organization's plan. See [Plans & limits](/limits/plans).
- Writing is a pull path only. The plugin writes what your role can read; uploading secret files happens in the dashboard or CLI.
- Reveals and other sensitive actions are capability-gated. See [RBAC](/platform/rbac).
## Next
- [Linking & sync](/jetbrains/sync)
- [Protection](/jetbrains/protection)
========================================================================
LINKING & SYNC
========================================================================
Source: https://docs.envpilot.dev/jetbrains/sync
# Linking & sync
## Link a directory
**Tools → Envpilot → Pull Now** syncs on demand. **Show Status** reports connection and sync state.
## What sync does
- The loop runs every `syncIntervalSeconds` (default 300, clamped to 60–3600) while `autoSync` is on.
- A live connection pushes changes the moment they land server-side, and pulls when you return to the IDE. The interval loop is the fallback, not the source of fresh values.
- Timer, realtime pushes, IDE activation, and manual pulls are single-flight. One write at a time, never interleaved files.
- `autoUnsyncOnClose` (on by default) removes the synced `.env` files when the project closes, so a laptop left open is not a `.env` archive.
## Secret files
Secret files are written with their own recorded permissions. A keystore gets `0600`/`0400`, not a `.env`'s bits.
The fetch completes and decrypts fully before any write starts. Incomplete or undecryptable data aborts the pull loudly instead of half-writing a file.
## Settings
**Settings → Tools → Envpilot**:
| Setting | Default | What it does |
| --------------------- | ------------ | --------------------------------------------------- |
| `autoSync` | on | Run the sync loop |
| `syncIntervalSeconds` | 300 | Permission-check loop, clamped 60–3600 |
| `targetFile` | `.env.local` | The file variables are written to |
| `autoUnsyncOnClose` | on | Delete synced `.env` files when the project closes |
| `conflictResolution` | `merge` | What happens when the target file already exists |
| `idlePauseMinutes` | 0 | Pause the loop after N idle minutes; 0 never pauses |
| `serverUrl` | baked | Override the Envpilot API base URL |
## Limits
- One project and one environment per linked directory. Link different directories to different environments; each syncs independently.
- The interval loop is a permission check, not a value poll. Values arrive over the live connection.
- Sync writes what your role can read. Nothing indicates that other variables exist.
## Next
- [Protection](/jetbrains/protection)
- [Secret files](/platform/secret-files)
========================================================================
PROTECTION
========================================================================
Source: https://docs.envpilot.dev/jetbrains/protection
# Protection
A synced `.env` is plaintext on your disk. The plugin adds guards around it. They live in **Tools → Envpilot**.
## Value cloaking
`cloakValues` (on by default) masks managed values in the editor. **Toggle Cloaking** flips it, **Reveal Values for 30 Seconds** unmasks temporarily and re-masks itself, and **Reveal Value at Caret** shows one value after the server checks your permissions.
Reveals are capability-gated: the server checks your role before answering. Re-masking is always allowed.
## Clipboard guard
Copy and cut are blocked inside Envpilot-managed files. "Copy the whole `.env` into a chat window" is how most secrets actually travel, so the guard explains itself instead of copying.
## Drift detection
Managed files are watched. An edit that did not come from a pull is flagged, so what you run and what your team ships do not silently diverge. The write path is the dashboard, the CLI, or a [request](/platform/requests). Editing the file on disk is not a write path.
## Commit guard
**Install Commit Guard** blocks commits that would carry an env file out of the repo. **Remove Commit Guard** takes it off again. Both toggles are off by default (`commitGuardEnabled`, `commitGuardAutoInstall`) and also live in **Settings → Tools → Envpilot**.
## Limits
- Guards apply to files Envpilot manages. A `.env` you created by hand is not tracked, and nothing about it is protected.
- The clipboard guard covers the editor's copy and cut. It cannot stop a screenshot or a file manager.
- If a secret is exposed anyway, [rotate it](/platform/rotation).
## Next
- [Troubleshooting](/jetbrains/troubleshooting)
- [Security](/platform/security)
========================================================================
TROUBLESHOOTING
========================================================================
Source: https://docs.envpilot.dev/jetbrains/troubleshooting
# Troubleshooting
**The tool window is gone.** **View → Tool Windows → Envpilot** brings it back.
**Sign-in does not complete.** The device-flow login opens in your browser and needs your approval there. Pop-up blockers are the usual suspect; copy the code from the IDE notification into the browser page by hand.
**Nothing syncs.** Run **Tools → Envpilot → Show Status** first. Then check the basics: signed in, directory still linked, `autoSync` on, and your role can still read the project. A revoked access means no pull, and files removed by `autoUnsyncOnClose` stay gone.
**Pull says a value cannot be decrypted.** The pull aborts instead of writing partial data, by design. Re-pull once; if it persists, rewrite the value from the dashboard so it gets a fresh vault reference.
**The plugin does not load.** It needs an IntelliJ-platform IDE 2025.1 or newer. **Help → About** shows the IDE version; **Settings → Plugins** shows the installed plugin version.
**Still stuck.** [Open an issue](https://github.com/rafay99-epic/envpilot.dev/issues/new) with the IDE version, the plugin version, and what **Show Status** reported.
========================================================================
GITHUB ACTION OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/action/overview
# GitHub Action overview
`rafay99-epic/envpilot-action@v1` pulls your project's variables into a workflow job, masks every value in the log, and optionally materialises [secret files](/action/secret-files) on the runner.
## Create a key
## Usage
```yaml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Pull environment variables
uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
- name: Deploy
run: ./deploy.sh # DATABASE_URL, API_SECRET, … are already in the environment
```
By default (`export-env: "true"`) every pulled variable is appended to `$GITHUB_ENV`, so it becomes a normal environment variable for every later step in the job. No extra wiring.
To write a dotenv file instead of, or alongside, exporting:
```yaml
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
export-env: "false"
env-file: .env
```
The dotenv file is written with mode `0600`.
## Versioning
`@v1` is a floating major tag: it always points at the newest 1.x release, which is how you get non-breaking updates without editing every workflow. Pin `@vX.Y.Z` instead if you need a frozen build.
## Limits
- **Read-only.** The key pulls variables and files. It can never create, edit, delete, or [file a request](/platform/requests) — CI reads, it does not negotiate.
- **`public_api` tier gate.** Machine access is a Pro feature.
- **One environment per step.** Pull twice for two environments.
- **`project` is required for files.** The files endpoint is project-scoped even when the token's scope resolves a single project.
- **Rate limited per key.** See [Rate limits](/limits/rate-limits).
- Runs on `node24` (Node LTS). Needs Actions runner v2.327.1 or newer — GitHub-hosted runners always are; self-hosted runners pinned below that must update.
## Next
- [Inputs & outputs](/action/reference)
- [Secret files in CI](/action/secret-files)
- [Recipes](/action/recipes) · [Security](/action/security)
========================================================================
INPUTS & OUTPUTS
========================================================================
Source: https://docs.envpilot.dev/action/reference
# Inputs & outputs
## Inputs
{/* generated:action-inputs start */}
| Input | Required | Default | What it does |
| ------------- | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token` | Yes | — | Envpilot API key (envpk\_…) with the GitHub Action surface, scoped to a project and environment. Create one under Organization → Settings → API Keys. |
| `environment` | Yes | — | Environment to pull variables from, e.g. production, staging, development. |
| `api-url` | No | `https://www.envpilot.dev` | Envpilot API base URL. Override only for self-hosted or non-production instances. |
| `export-env` | No | `true` | When true, exports every pulled variable to $GITHUB_ENV so later steps can read it directly. |
| `env-file` | No | — | Optional path to write the pulled variables as a dotenv file. Skipped when unset. |
| `files` | No | `false` | When true, also pulls the project's secret files (keystores, SSH keys, certificates) and writes each to its recorded path. The API key must carry the 'files' resource, which is never granted by default. |
| `project` | No | — | Project slug. Required when files is true — the files endpoint is project-scoped, unlike variables which the token scope resolves. |
| `files-dir` | No | — | Directory that secret file paths are resolved against. Defaults to the workspace root. |
{/* generated:action-inputs end */}
## Outputs
{/* generated:action-outputs start */}
| Output | What it holds |
| ------------- | --------------------------------------------------------- |
| `count` | Number of variables pulled from Envpilot. |
| `files-count` | Number of secret files written. Zero when files is false. |
{/* generated:action-outputs end */}
Using an output:
```yaml
- id: envpilot
uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
- run: echo "Pulled ${{ steps.envpilot.outputs.count }} variables"
```
## Limits
- Inputs are strings — GitHub Actions has no boolean type. `export-env: "true"` and `files: "true"` are quoted on purpose.
- `api-url` exists for non-production instances; leave it unset otherwise.
- `project` is ignored unless `files: true`, where it is required.
- `files-dir` is created if it does not exist, then resolved to a real path before anything is written.
========================================================================
SECRET FILES IN CI
========================================================================
Source: https://docs.envpilot.dev/action/secret-files
# Secret files in CI
A release build usually needs more than variables: a signing keystore, a `.p12`, a service-account JSON. `files: true` writes them onto the runner at their recorded paths, with their recorded modes.
```yaml
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
project: mobile-app # required when files: true
files: true
```
After that step, `android/app/upload.jks` (or whatever path the file records) exists, mode `0600`, ready for the build.
## Requirements
| Requirement | Why |
| --------------------------- | --------------------------------------------------------------- |
| `files: true` | Off by default — no workflow pulls file contents unless it asks |
| `project: ` | The files endpoint is project-scoped, unlike variables |
| `files` resource on the key | Never granted by default; select it when minting the key |
Without the resource the request is refused — the key is not "partially allowed".
## Where files land
`files-dir` sets the root that destination paths resolve against; it defaults to the workspace. The directory is created if missing, then resolved to a real path.
```yaml
with:
files: true
project: mobile-app
files-dir: build/secrets
```
## How the write is protected
The server validates every path, and the Action re-validates on the runner, because it is the process actually creating files. A server bug or a tampered response must not be able to write outside the workspace:
- absolute paths are refused
- paths that escape the root are refused, including through a **symlinked** intermediate directory
- writing **through** a symlink is refused
- content is staged into a fresh exclusive temp file at the restrictive mode and renamed over the target, so a pre-existing world-readable file never holds new secret contents at its old mode
## Batching and the 8 MiB rule
Files are fetched in batches sized under the server's per-request ceiling of **8 MiB**, so a project with many files still pulls in one step. A **single file** larger than 8 MiB cannot be fetched at all — the Pro per-file limit is 8 MB precisely so this cannot happen through the product.
The step logs name, path and size only. Contents never reach the log: masking a multi-megabyte binary is not meaningful, so the rule is that it never gets there.
## Output
`files-count` holds the number of files written — zero when `files` is false.
```yaml
- id: envpilot
uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
project: mobile-app
files: true
- run: echo "wrote ${{ steps.envpilot.outputs.files-count }} files"
```
## Limits
- Every file fetch is **audited** against the API key. A workflow that pulls on every push produces an audit entry per run — by design.
- Content reads are rate limited: refill 60/min, burst 1000. A cold pull of any legal project fits in one burst.
- The Action **overwrites** whatever is at the destination path. Unlike `envpilot files pull`, there is no conflict check — a runner is expected to be empty.
- No upload path. CI writes to disk, never to Envpilot.
## Next
- [Android keystore in CI](/guides/android-keystore-ci) — the full workflow
- [Secret files](/platform/secret-files) — the object model
========================================================================
RECIPES
========================================================================
Source: https://docs.envpilot.dev/action/recipes
# Recipes
## One workflow, several environments
Use a matrix and one key per environment, or one key scoped to all three:
```yaml
jobs:
deploy:
strategy:
matrix:
environment: [staging, production]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: ${{ matrix.environment }}
- run: ./deploy.sh
```
Matrix jobs fan out, and the value-pull bucket is sized for exactly that: full capacity is available as a burst.
## Dotenv file for a container build
```yaml
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
export-env: "false"
env-file: .env.production
- run: docker build --secret id=env,src=.env.production .
```
## Monorepo: two projects in one job
```yaml
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_API_TOKEN }}
environment: production
export-env: "false"
env-file: apps/api/.env
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_WEB_TOKEN }}
environment: production
export-env: "false"
env-file: apps/web/.env
```
Two keys, two scopes, two files. Exporting both to `$GITHUB_ENV` would collide on shared key names — write files instead.
## Mobile release build with a keystore
```yaml
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
project: mobile-app
files: true
- run: ./gradlew bundleRelease
```
Full walkthrough: [Android keystore in CI](/guides/android-keystore-ci).
## Only pull on the branches that need it
```yaml
- uses: rafay99-epic/envpilot-action@v1
if: github.ref == 'refs/heads/main'
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
```
Every pull is audited and rate-limited. A pull-request job that does not need production secrets should not draw them.
## Fork pull requests
Repository secrets are not available to workflows triggered by a fork, so the token input is empty and the step fails. That is the correct outcome — gate the step on `github.event.pull_request.head.repo.full_name == github.repository` rather than trying to work around it.
## Limits
- One environment per step; one project per step for files.
- `$GITHUB_ENV` is per job, not per workflow. Downstream jobs need their own pull.
- Values are masked in logs, but a step that writes a value into a file you upload has left masking behind.
========================================================================
ACTION SECURITY
========================================================================
Source: https://docs.envpilot.dev/action/security
# Action security
## Masking comes first
Every pulled value is registered with GitHub's log masking (`core.setSecret`) **before** anything exports or writes it. Order matters: a value exported first and masked second is a value that can appear in a log line in between.
Because masking happens at the runner level, a later step that accidentally echoes a variable prints `***`. Empty values are skipped — masking the empty string would redact every subsequent character of the log.
Secret file **contents** are never logged at all. Only name, path and size reach the log, because masking a multi-megabyte binary is not meaningful.
## What the key can do
| Can | Cannot |
| -------------------------------------------- | ------------------------------------------------ |
| Read variables in its scope | Create, edit, or delete anything |
| Read secret files, with the `files` resource | File a variable [request](/platform/requests) |
| Nothing else | Reach projects or environments outside its scope |
The Action is the narrowest surface Envpilot has. CI reads; it does not negotiate.
## Scope it tightly
- One project, one environment per key wherever practical.
- Add the `files` resource only to keys that pull files.
- Give a key an expiry if the pipeline is temporary.
- Prefer separate keys per repository, so revoking one does not break the others.
## Revocation
Revoke in **Organization → Settings → API Keys**. The next pull is rejected immediately — there is no cached-authorization window and no grace period. Unknown, revoked, and expired keys all get the same uniform answer, so a scanner cannot use the error to tell which it is holding.
## Every pull is audited
Each value-returning request is written to the audit log against the key: which project, which environment, which resource. If a key is compromised, the audit log tells you exactly what it read and when. Denials are logged too.
## Runners are ephemeral
GitHub-hosted runners are destroyed after the job, so pulled values do not persist — **unless you make them persist**. Uploading an artifact containing a dotenv file, caching a directory that holds a keystore, or writing to a mounted volume all outlive the job.
## Limits
- Log masking protects the log, not a file you upload.
- Self-hosted runners are not ephemeral. Clean them up yourself, and prefer `env-file` paths inside the workspace so a checkout wipe removes them.
- Rate limits apply per key, not per repository — several workflows sharing one key share its bucket.
- A key with a tier downgrade behind it stops working: the `public_api` gate is re-checked on every request, not at creation.
## See also
- [API authentication](/api/authentication) — the full key model
- [Rate limits](/limits/rate-limits)
========================================================================
DOCKER OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/docker/overview
# Docker overview
`ghcr.io/rafay99-epic/envpilot:1` is a single statically linked binary. Copy it into your image, or mount it during a build, and your project's variables and [secret files](/docker/runtime#secret-files) are there.
No Node, no Python, no shell, no libc required. It runs in `scratch`, distroless, `alpine` (musl), `python:3.12-slim`, `golang`, `eclipse-temurin` — whatever base image you are already on.
## Pick your case
| You want | Read |
| -------------------------------------------- | --------------------------------- |
| Secrets available while `docker build` runs | [Build time](/docker/build-time) |
| Secrets in the app when the container starts | [Runtime](/docker/runtime) |
| A Compose stack | [Docker Compose](/docker/compose) |
| Every flag and exit code | [Reference](/docker/reference) |
## Sixty-second setup
Your variables are in the environment before `python app.py` starts, and nothing was written to disk.
## How it gets in
Two mechanisms, depending on when you need the secrets.
**At build time** the binary is _mounted_, so it never becomes a layer in your image and neither do the values:
```dockerfile
RUN --mount=type=secret,id=envpilot_token \
--mount=from=ghcr.io/rafay99-epic/envpilot:1,source=/envpilot,target=/envpilot \
ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
/envpilot exec --project checkout-api --env production -- npm ci
```
**At runtime** the binary is _copied_ in, because it has to be present when the container starts:
```dockerfile
COPY --from=ghcr.io/rafay99-epic/envpilot:1 /envpilot /usr/local/bin/envpilot
ENTRYPOINT ["envpilot", "exec", "--"]
```
Same binary, same flags, both cases.
## The three commands
| Command | Does |
| ------------------------ | ------------------------------------------------------------- |
| `envpilot pull` | Write variables as dotenv text. Stdout unless `--out` is set. |
| `envpilot files` | Write secret files to their recorded paths at `0600`/`0400`. |
| `envpilot exec -- ` | Inject variables into `` and run it. Nothing hits disk. |
There is no `login` and no config file. The binary reads its inputs, makes one or two HTTPS calls, and does one thing.
## Passing the key
| Variable | Notes |
| --------------------- | ---------------------------------------- |
| `ENVPILOT_TOKEN_FILE` | Path to a mounted secret. **Preferred.** |
| `ENVPILOT_TOKEN` | The key inline. |
`ENVPILOT_TOKEN_FILE` wins when both are set.
Prefer the file. An environment variable is readable through `docker inspect` and `/proc//environ` by anyone with access to the daemon, while a Compose or BuildKit secret is a tmpfs mount that never touches the image.
There is deliberately no `--token` flag: a credential on a command line shows up in `ps`, in shell history, and in build logs.
## Pinning
```
ghcr.io/rafay99-epic/envpilot:1 # floating major, gets non-breaking updates
ghcr.io/rafay99-epic/envpilot:1.0.0 # exact, you control upgrades
```
## Requirements
Docker is its own surface, with its own plan feature (`docker_image`) and its own place on your API keys. It is not a mode of the REST API: your plan can include container delivery without including the public API, and turning one off never touches the other.
Create the key under **Organization → Settings → API Keys** and pick the **Docker** preset, or tick **Docker** under Advanced. A key minted only for the REST API or the GitHub Action will be refused here, on purpose.
Scope the key to the projects and environments it actually serves. One key per service keeps a leaked container credential from reaching everything else.
========================================================================
BUILD TIME
========================================================================
Source: https://docs.envpilot.dev/docker/build-time
# Build time
Use this when the build itself needs a credential: installing from a private registry, compiling against a licensed SDK, running a migration, signing an artifact.
The rule for all of it: **the values must not survive into the image.** Two BuildKit features do that for you.
- `--mount=type=secret` puts the API key in the build without it entering image history.
- `--mount=from=` mounts the Envpilot binary for one instruction, so it never becomes a layer either.
## Variables
```dockerfile
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=envpilot_token \
--mount=from=ghcr.io/rafay99-epic/envpilot:1,source=/envpilot,target=/envpilot \
ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
/envpilot exec --project checkout-api --env production -- npm ci
COPY . .
RUN npm run build
```
Build it with the key passed as a secret:
```bash
docker build --secret id=envpilot_token,src=./.envpilot-token .
```
`npm ci` sees `NPM_TOKEN` (or whatever your project stores) in its environment. The next layer does not.
The `# syntax=docker/dockerfile:1` line is required — it opts the build into BuildKit's mount syntax.
## Secret files
Same shape. The difference is that files land on the filesystem, so delete them in the **same** `RUN` instruction. A file removed in a later instruction is still recoverable from the earlier layer.
```dockerfile
RUN --mount=type=secret,id=envpilot_token \
--mount=from=ghcr.io/rafay99-epic/envpilot:1,source=/envpilot,target=/envpilot \
ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
/envpilot files --project mobile --env production --dir /build \
&& ./gradlew assembleRelease \
&& rm -rf /build/*.jks
```
The key needs the `files` resource, which is never granted by default.
## Both at once
`exec --files` writes the secret files, then runs your command with the variables injected:
```dockerfile
RUN --mount=type=secret,id=envpilot_token \
--mount=from=ghcr.io/rafay99-epic/envpilot:1,source=/envpilot,target=/envpilot \
ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
/envpilot exec --project mobile --env production --files --dir /build \
-- ./gradlew assembleRelease \
&& rm -rf /build/*.jks
```
## A dotenv file for a later step
When a build tool insists on reading a file rather than the environment, write one and remove it in the same instruction:
```dockerfile
RUN --mount=type=secret,id=envpilot_token \
--mount=from=ghcr.io/rafay99-epic/envpilot:1,source=/envpilot,target=/envpilot \
ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
/envpilot pull --project web --env production --out .env.production \
&& npm run build \
&& rm -f .env.production
```
`pull --out` writes at mode `0600`.
## Getting the key to the build
In CI you usually already have it in a secret store. Feed it in without ever writing it to the workspace:
```bash
# GitHub Actions
docker build --secret id=envpilot_token,env=ENVPILOT_TOKEN .
```
```bash
# Locally, from a file you keep out of git
echo ".envpilot-token" >> .gitignore
docker build --secret id=envpilot_token,src=./.envpilot-token .
```
Compose reads the same secrets in a `build:` block — see [Docker Compose](/docker/compose#build-secrets).
## What not to do
```dockerfile
# Wrong — the key is now permanently in the image
ARG ENVPILOT_TOKEN
RUN /envpilot pull --out .env
```
```dockerfile
# Wrong — .env is baked into this layer forever, even if a later RUN deletes it
RUN --mount=type=secret,id=envpilot_token /envpilot pull --out .env
RUN npm run build
RUN rm .env
```
Keep the fetch, the use, and the cleanup inside one `RUN`.
## Caching
BuildKit caches a `RUN` layer on its instruction text, not on what the secret contained. A rotated variable will **not** invalidate the cache on its own. If a build must always see current values, either bust it explicitly or move the fetch to [runtime](/docker/runtime), which is the better answer for anything that is not needed to produce the artifact.
## Troubleshooting
| What you see | What it means |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `--mount option is not supported` | BuildKit is off. Add `# syntax=docker/dockerfile:1` and set `DOCKER_BUILDKIT=1`. |
| `ENVPILOT_TOKEN_FILE points at … which could not be read` | The `--secret id=` name does not match the path. The default mount is `/run/secrets/`. |
| `no such file or directory: /envpilot` | The `--mount=from=` line is missing, or `source=`/`target=` do not match. |
| A 403 naming the environment | The key is not scoped to that environment. |
| A 403 mentioning Pro | The org is not on a plan that includes the public API. |
Every exit code is listed in the [reference](/docker/reference#exit-codes).
========================================================================
RUNTIME
========================================================================
Source: https://docs.envpilot.dev/docker/runtime
# Runtime
This is the one most people want. The container starts, Envpilot fetches, your app gets its environment, and nothing decrypted is ever written to a filesystem.
Copy the binary into your image and make it the entrypoint.
```dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/rafay99-epic/envpilot:1 /envpilot /usr/local/bin/envpilot
COPY . /app
WORKDIR /app
ENTRYPOINT ["envpilot", "exec", "--"]
CMD ["python", "app.py"]
```
`ENTRYPOINT` plus `CMD` is the useful split: `CMD` stays overridable, so `docker run myapp python manage.py migrate` still works and still gets the variables.
## Any base image
The binary is statically linked and carries no runtime and no libc, so it drops into whatever you are already using — including `scratch`, which has no dynamic loader at all.
```dockerfile
# Go, distroless
FROM gcr.io/distroless/base-debian12
COPY --from=ghcr.io/rafay99-epic/envpilot:1 /envpilot /usr/local/bin/envpilot
COPY --from=build /out/server /server
ENTRYPOINT ["/usr/local/bin/envpilot", "exec", "--"]
CMD ["/server"]
```
```dockerfile
# Java
FROM eclipse-temurin:21-jre-alpine
COPY --from=ghcr.io/rafay99-epic/envpilot:1 /envpilot /usr/local/bin/envpilot
COPY target/app.jar /app.jar
ENTRYPOINT ["envpilot", "exec", "--"]
CMD ["java", "-jar", "/app.jar"]
```
## Passing the key
Mount it as a file. An environment variable is readable through `docker inspect` and `/proc//environ` by anyone with access to the daemon.
```bash
docker run \
-v /etc/envpilot/token:/run/secrets/envpilot_token:ro \
-e ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
-e ENVPILOT_PROJECT=checkout-api \
-e ENVPILOT_ENVIRONMENT=production \
myapp:latest
```
Project and environment are ordinary configuration, not secrets, so environment variables are fine for those. Keeping them out of the image is what lets one image serve staging and production.
## Secret files
Certs, keystores and SSH keys land at their recorded paths just before your app starts:
```dockerfile
ENTRYPOINT ["envpilot", "exec", "--files", "--"]
CMD ["/server"]
```
Add `--dir` to place them somewhere other than the working directory. The key needs the `files` resource, which is never granted by default.
For anything long-lived, mount a tmpfs so the values never touch the container's writable layer:
```bash
docker run --tmpfs /secrets:rw,mode=0700 \
-e ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
myapp:latest
```
## Signals and exit codes
`exec` is a thin wrapper, not a supervisor. It forwards `SIGINT`, `SIGTERM`, `SIGHUP` and `SIGQUIT` to your process, so `docker stop` reaches your app and your graceful shutdown runs normally.
Your app's exit code becomes the container's exit code. A process killed by a signal reports `128 + signal`, the same convention a shell uses. Restart policies, health checks and `docker wait` behave exactly as they would without the wrapper.
## When it cannot fetch
The container **does not start**. That is deliberate: an app running on half its configuration fails later, in a harder place to diagnose, and often after it has already accepted traffic. A container that refuses to start is caught by your restart policy and your alerting immediately.
The same rule applies mid-pull. If any variable comes back without a value, the whole pull aborts rather than handing your app a blank credential.
## Rotation
Variables are read once, at start. A value changed in Envpilot reaches the container on its next restart:
```bash
docker restart checkout-api
```
If you need something faster than a restart, pull on a schedule with [`envpilot pull --out`](/docker/reference#pull) and have your app watch the file, or write a small unit that restarts the service after a successful pull.
## Startup cost
One or two HTTPS requests, typically well under a second. If your platform runs an aggressive startup probe, give it a couple of seconds of grace.
Container restarts share a per-key rate limit. A fleet restarting at once on a **single** key will hit it — the binary honours the server's `Retry-After` and backs off, but the cleaner fix is one key per service, which also shrinks what a leaked credential can reach.
## Troubleshooting
| What you see | What it means |
| ------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `exec: envpilot: not found` | The `COPY --from` line is missing, or the target is not on `PATH`. |
| `envpilot: No API key` | Neither `ENVPILOT_TOKEN_FILE` nor `ENVPILOT_TOKEN` reached the container. |
| `envpilot: No project` / `No environment` | Pass `--project` / `--env`, or set `ENVPILOT_PROJECT` / `ENVPILOT_ENVIRONMENT`. |
| Container exits `2` immediately | Bad invocation, not a network problem. The message names what is missing. |
| `rate limited … waiting Ns` | Expected on a mass restart. Split keys per service. |
| The app sees a Dockerfile `ENV` value, not yours | It does not — Envpilot values overwrite existing entries. Check the environment name. |
========================================================================
DOCKER COMPOSE
========================================================================
Source: https://docs.envpilot.dev/docker/compose
# Docker Compose
Compose has real secret support. Use it — the token arrives as a tmpfs mount instead of an environment variable, so it stays out of `docker inspect`.
## The whole thing
```yaml
services:
api:
build: .
environment:
ENVPILOT_TOKEN_FILE: /run/secrets/envpilot_token
ENVPILOT_PROJECT: checkout-api
ENVPILOT_ENVIRONMENT: production
secrets: [envpilot_token]
ports: ["8080:8080"]
secrets:
envpilot_token:
file: ./.envpilot-token
```
With the Dockerfile from [runtime](/docker/runtime):
```dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/rafay99-epic/envpilot:1 /envpilot /usr/local/bin/envpilot
COPY . /app
WORKDIR /app
ENTRYPOINT ["envpilot", "exec", "--"]
CMD ["python", "app.py"]
```
Then:
```bash
echo ".envpilot-token" >> .gitignore
docker compose up
```
## Several services, one project
Each service names its own environment, so a single Compose file can run production and a staging worker side by side.
```yaml
services:
api:
build: ./api
environment:
ENVPILOT_TOKEN_FILE: /run/secrets/envpilot_token
ENVPILOT_PROJECT: checkout-api
ENVPILOT_ENVIRONMENT: production
secrets: [envpilot_token]
worker:
build: ./worker
environment:
ENVPILOT_TOKEN_FILE: /run/secrets/envpilot_token
ENVPILOT_PROJECT: checkout-api
ENVPILOT_ENVIRONMENT: production
secrets: [envpilot_token]
secrets:
envpilot_token:
file: ./.envpilot-token
```
A key per service is better than one shared key when you can manage it: revoking a leaked worker credential should not take the API down with it, and it keeps each service off the others' rate-limit bucket.
## Reading the token from the host environment
Useful in CI, where the key is already in a secret store and you would rather not write it to a file:
```yaml
secrets:
envpilot_token:
environment: ENVPILOT_TOKEN
```
```bash
ENVPILOT_TOKEN="envpk_…" docker compose up
```
Compose still delivers it to the container as a file at `/run/secrets/envpilot_token`, so nothing in the service definition changes.
## Build secrets
Compose passes secrets to a build the same way. See [build time](/docker/build-time) for what to do with them inside the Dockerfile.
```yaml
services:
web:
build:
context: .
secrets: [envpilot_token]
environment:
ENVPILOT_TOKEN_FILE: /run/secrets/envpilot_token
ENVPILOT_PROJECT: web
ENVPILOT_ENVIRONMENT: production
secrets: [envpilot_token]
secrets:
envpilot_token:
file: ./.envpilot-token
```
The `secrets:` under `build:` and the one under the service are separate: the first is available during `docker compose build`, the second inside the running container.
## For an image you do not control
Sometimes you cannot add a binary to the image — a third-party database, an off-the-shelf service. Pull on the host and hand the result over as an env file:
```bash
envpilot_pull() {
docker run --rm \
-v "$PWD/.envpilot-token:/run/secrets/envpilot_token:ro" \
-e ENVPILOT_TOKEN_FILE=/run/secrets/envpilot_token \
ghcr.io/rafay99-epic/envpilot:1 \
pull --project "$1" --env "$2" --quiet
}
envpilot_pull checkout-api production > .env.runtime
docker compose --env-file .env.runtime up -d
rm -f .env.runtime
```
## Health checks
`exec` execs your process, so a health check written against your app works unchanged:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
start_period: 10s
```
Give `start_period` a couple of seconds more than usual to cover the fetch.
## Picking up rotated values
Variables are read at start, so a change in Envpilot reaches the stack on the next restart:
```bash
docker compose restart api
```
========================================================================
DOCKER REFERENCE
========================================================================
Source: https://docs.envpilot.dev/docker/reference
# Docker reference
```
ghcr.io/rafay99-epic/envpilot:1
ghcr.io/rafay99-epic/envpilot:1.0.0
```
Multi-arch: `linux/amd64` and `linux/arm64`. The image contains one file, `/envpilot`, and nothing else — no shell, no package manager, no runtime, no libc. It is a statically linked Go binary, which is what lets the same file run in `scratch` and in `alpine` (musl) and in `debian` (glibc).
The binary sits at the image root rather than on a `PATH` on purpose: you choose the name it takes in your image, so it can never shadow the [Envpilot CLI](/cli/overview) on a developer machine.
## Commands
### pull
```
envpilot pull [flags]
```
Writes variables as dotenv text. Stdout by default, or to `--out ` at mode `0600`.
Every value is single-quoted with embedded quotes escaped, so values containing spaces, `#`, `$` or newlines survive `set -a; . file` and Compose's `env_file`.
```bash
envpilot pull --project checkout-api --env production
envpilot pull --project checkout-api --env production --out .env.production
```
### files
```
envpilot files [flags]
```
Writes secret files to their recorded paths under `--dir` (default: the working directory), at mode `0600` or `0400` as configured on each file.
Requires the `files` resource on the key, which is never granted by default.
```bash
envpilot files --project mobile --env production --dir /build
```
### exec
```
envpilot exec [flags] -- [args...]
```
Fetches variables, merges them into the child's environment, and runs the command. Nothing decrypted is written to a filesystem. Envpilot values overwrite entries already present, because a Dockerfile `ENV` is a default and Envpilot is the source of truth.
Add `--files` to write secret files first.
```bash
envpilot exec --project checkout-api --env production -- ./server
envpilot exec --files --dir /secrets -- ./server
```
Everything after `--` belongs to your command and is passed through untouched, including its own flags.
## Flags
| Flag | Short | Applies to | Default | Meaning |
| ------------------ | ----- | --------------- | -------------------------- | ------------------------------------ |
| `--project ` | `-p` | all | `$ENVPILOT_PROJECT` | Project slug |
| `--env ` | `-e` | all | `$ENVPILOT_ENVIRONMENT` | Environment name |
| `--out ` | `-o` | `pull` | stdout | Write here at `0600` |
| `--dir ` | `-d` | `files`, `exec` | working directory | Output directory for secret files |
| `--files` | | `exec` | off | Write secret files before running |
| `--api-url ` | | all | `https://www.envpilot.dev` | API base URL |
| `--quiet` | `-q` | all | off | Suppress the progress line on stderr |
| `--help` | `-h` | | | Usage |
| `--version` | `-v` | | | Version |
Flags win over environment variables, so one image can serve several environments.
## Environment variables
| Variable | Required | Meaning |
| ---------------------- | -------- | ---------------------------------------------------- |
| `ENVPILOT_TOKEN_FILE` | one of | Path to a mounted secret holding the key. Preferred. |
| `ENVPILOT_TOKEN` | one of | The key inline. |
| `ENVPILOT_PROJECT` | Yes\* | Project slug. \*Unless `--project` is passed. |
| `ENVPILOT_ENVIRONMENT` | Yes\* | Environment. \*Unless `--env` is passed. |
| `ENVPILOT_API_URL` | No | API base URL. |
`ENVPILOT_TOKEN_FILE` wins when both credentials are set. A trailing newline in the file is trimmed, since mounted secrets almost always carry one.
There is no `--token` flag by design: a credential on a command line is visible in `ps`, in shell history, and in build logs.
## Exit codes
| Code | Meaning |
| ------------ | --------------------------------------------------------------------- |
| `0` | Success |
| `1` | Request or write failure |
| `2` | Bad invocation: missing key, project, environment, or an unknown flag |
| child's code | `exec` exits with whatever your command exited with |
| `128 + N` | `exec`, when the child was killed by signal `N` |
## Key requirements
Create keys in **Organization → Settings → API Keys**.
| Need | Grant |
| ----------------------- | ---------------------------------------------------- |
| `pull`, `exec` | `variables` resource |
| `files`, `exec --files` | `files` resource (never granted by default) |
| Any command | Scope to one project and the environments you deploy |
Docker is an independent surface. The key must carry the **Docker** surface, and your plan must include the `docker_image` feature. A key scoped only to the REST API or the GitHub Action is refused, and disabling the public API does not disable Docker.
Every request the image makes sends `surface=docker`, so both the key's scope and the plan feature are checked on each call, not just at mint time.
## Behaviour worth knowing
**Failures are total.** If any variable comes back without a value, the entire pull aborts. Partial configuration fails later and in a worse place than a container that refuses to start.
**Rate limits are honoured.** On a `429` the binary waits exactly as long as the server's `Retry-After` header asks, capped at 60 seconds, for up to five attempts. Large secret-file pulls are split into batches under 6 MiB, so they can legitimately hit the limit mid-pull.
Docker has its own bucket — 120 requests of burst refilling at 30/min per key — so a restarting fleet cannot spend your CI pipeline's budget, and vice versa. See [rate limits](/limits/rate-limits). Your plan also caps how many active Docker keys an organization may hold.
**Values are never logged.** Progress goes to stderr and names keys, paths and byte counts only. Contents never reach the log in the first place, which is why `pull` can stream dotenv text on stdout safely.
**Paths are checked locally.** Secret file paths come from the server and the server validates them, but the binary re-checks anyway: absolute paths, traversal, and writes through a symlink are all refused. Files are staged through an exclusive temp and renamed into place, so an existing file never holds new secret contents at its old permissions.
## Errors
| Message | Cause |
| --------------------------------------------------- | --------------------------------------------------------------------------------- |
| `No API key` | Neither credential variable was set. |
| `ENVPILOT_TOKEN_FILE points at … could not be read` | Wrong mount path. The Compose/BuildKit default is `/run/secrets/`. |
| `No project` / `No environment` | Missing flag and missing environment variable. |
| `Invalid or revoked` (401) | Unknown, revoked or expired key. |
| `not scoped to` (403) | Key does not cover that environment or project. |
| A message naming Pro (403) | Org plan does not include the public API. |
| `Refusing a partial pull` | A variable could not be decrypted. Retry; if it persists, re-save it in Envpilot. |
The full REST error list is in the [API errors](/api/errors) reference.
## Source
The image is built from `packages/docker-image` and published to [rafay99-epic/envpilot-docker](https://github.com/rafay99-epic/envpilot-docker) on every release. MIT licensed.
========================================================================
API OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/api/overview
# API overview
```
https://www.envpilot.dev/api/v1
```
Every endpoint requires `Authorization: Bearer ` and every endpoint is **read-only**. Requires the Pro plan (`public_api`).
## Endpoints
| Endpoint | Returns | Resource |
| ----------------------------------------------------- | --------------------------------- | ----------- |
| [`GET /v1/organization`](/api/organization) | Organization metadata | any key |
| [`GET /v1/projects`](/api/projects) | Projects in scope | `projects` |
| [`GET /v1/projects/{slug}`](/api/projects) | One project | `projects` |
| [`GET /v1/projects/{slug}/variables`](/api/variables) | Variables, with or without values | `variables` |
| [`GET /v1/projects/{slug}/accounts`](/api/accounts) | Shared accounts | `accounts` |
| [`GET /v1/files`](/api/files) | Secret files | `files` |
## Conventions
- **JSON by default.** `format=env` on the variables endpoint returns dotenv text instead.
- **Errors are `{ "error": string, "code": string }`**, plus an `x-request-id` header worth quoting to support. See [Errors](/api/errors).
- **Environments** are exactly `development`, `staging`, `production`.
- **Timestamps** are epoch milliseconds.
## Three rules that shape everything
### Reads are bounded, and never partial
A response that would exceed 1000 items is **refused** with `422` rather than truncated. A silently short list is worse than an error: a deploy that starts with three variables missing fails somewhere far from the cause.
### Failure is loud
If a vault decrypt fails for any item in a response, the whole request aborts with `503`. You never receive a partial set, and never a sentinel value standing in for a secret.
### Scope is invisible
A project outside your key's scope returns the same `404` as a project that does not exist. Existence cannot be probed by watching which error comes back.
## No pagination
There is deliberately no cursor or page parameter. The bounded-read rule means any successful response is complete, and any response that would not be complete is an error. If you are hitting the ceiling, you want more keys with narrower scope, not more pages.
## No CORS
The API sends no CORS headers and is not meant to be called from a browser. An API key in client-side code is a published key.
## Limits
- Read-only. There is no write endpoint on any surface; the only machine-initiated action is filing a [request](/platform/requests) over MCP.
- Pro plan required. The gate is re-checked on **every** request, so a downgrade stops access immediately.
- Rate limits are per key: 120/min metadata, 30/min values, plus separate buckets for [files](/api/files). See [Rate limits](/limits/rate-limits).
- Every value-returning call is audited against the key.
## Next
- [Quickstart](/api/quickstart) — first call end to end
- [Authentication](/api/authentication) — the key model
========================================================================
API QUICKSTART
========================================================================
Source: https://docs.envpilot.dev/api/quickstart
# API Quickstart
Envpilot's public REST API lets you read projects, variables, and shared accounts programmatically — no CLI or extension required. It's read-only in v1 and requires the **Pro** plan.
## Create an API key
Go to **Organization Settings → API Keys** — there's a single creation screen; project scope is a choice you make inside it, not a separate settings page.
1. Click **New API Key**
2. Select the surfaces where the key may authenticate: REST API, MCP server, and/or GitHub Action
3. Choose project, environment, and resource scopes (see below) — pick specific projects, or "all projects, including future ones" (owner-only)
4. Copy the key — it's shown **once**, as `envpk_...`. Envpilot only stores a hash of it; if you lose it, revoke it and create a new one — there's no rotate-in-place.
Org-wide keys (scope = all projects) can only be created by the organization **Owner**. Project-scoped keys can also be created by a Team Lead.
All project, environment, resource, and surface choices are immutable. If the required access changes, create a replacement key with the complete intended scope, switch clients to it, verify it, and then revoke the old key. For an MCP credential, follow [Create an MCP key](/mcp/overview#create-an-mcp-key) and [set `ENVPILOT_API_KEY`](/mcp/overview#set-envpilot-api-key) before configuring a client.
## Understand scope
Every key has three independent scope dimensions:
- **Projects** — `all` (every project in the org) or a specific list of projects
- **Environments** — `all` (development, staging, production) or a specific list
- **Resources** — which resource types the key can read: `variables`, `accounts`, `projects`, and optionally `requests` (the one write path — filing a variable request for a human to approve)
A key also carries immutable **surfaces** — which faces it may use: the REST API (`rest_api`), the MCP server (`mcp_server`), and the GitHub Action (`github_action`). All three route through the same enforcement core; see [Architecture](/start/architecture).
A key requesting a **project** outside its scope gets a `404` — identical to a project that genuinely doesn't exist, so a leaked key can't be used to probe which project slugs are real. A key requesting a **resource type, environment, or surface** outside its scope gets a `403` instead, since the project's existence is already implied by a URL the key can otherwise reach. This is deliberate: see [API Security](/api/authentication) for why.
## Pull your first variables
```bash
curl https://www.envpilot.dev/api/v1/projects/backend/variables?environment=production \
-H "Authorization: Bearer envpk_your_key_here"
```
Response:
```json
{
"variables": [
{
"key": "DATABASE_URL",
"value": "postgres://...",
"environments": ["production"],
"isSensitive": true,
"updatedAt": 1752192000000
},
{
"key": "API_SECRET",
"value": "sk_live_...",
"environments": ["staging", "production"],
"isSensitive": true,
"updatedAt": 1752192000000
}
]
}
```
`environment` is required unless you pass `metadata_only=true`. Each variable's `environments` array is its **full** scope — a key shared across staging and production shows both, even though you asked for `production` only. With `metadata_only=true`, the `value` field is omitted entirely (no vault round-trip happens).
## Filtering
**Exact keys** — pull only the variables you name:
```bash
curl "https://www.envpilot.dev/api/v1/projects/backend/variables?environment=production&keys=DATABASE_URL,API_SECRET" \
-H "Authorization: Bearer envpk_your_key_here"
```
**Prefix match** — useful for grabbing a related group, e.g. everything exposed to the client:
```bash
curl "https://www.envpilot.dev/api/v1/projects/backend/variables?environment=production&prefix=NEXT_PUBLIC_" \
-H "Authorization: Bearer envpk_your_key_here"
```
**Metadata only** — list variable keys without decrypting any values (no vault round-trip, higher rate limit):
```bash
curl "https://www.envpilot.dev/api/v1/projects/backend/variables?metadata_only=true" \
-H "Authorization: Bearer envpk_your_key_here"
```
**dotenv output** — get a ready-to-write `.env` file instead of JSON:
```bash
curl "https://www.envpilot.dev/api/v1/projects/backend/variables?environment=production&format=env" \
-H "Authorization: Bearer envpk_your_key_here"
# DATABASE_URL=postgres://...
# API_SECRET=sk_live_...
```
## Handling errors
Every error response — across all v1 endpoints — has the same shape: `{ "error": "", "code": "" }`, plus an `x-request-id` header worth logging for support.
| Status | Code | Meaning |
| ------ | ------------------- | ------------------------------------------------------------------------------------------------------- |
| 400 | `VALIDATION_ERROR` | Missing or malformed query params (e.g. no `environment` and no `metadata_only=true`) |
| 401 | `MISSING_TOKEN` | No `Authorization: Bearer` header |
| 401 | `INVALID_KEY` | Key is unknown, revoked, or expired — all three look identical to the caller |
| 403 | `FORBIDDEN_SCOPE` | Key doesn't include this resource type or environment |
| 403 | `FORBIDDEN_SURFACE` | Key isn't enabled for this surface (REST vs. MCP vs. GitHub Action) |
| 403 | `TIER_GATE` | Organization's plan no longer includes the public API |
| 404 | `NOT_FOUND` | Project outside the key's scope, or genuinely doesn't exist — see [Understand scope](#understand-scope) |
| 422 | `OVERFLOW` | Project exceeds the 1000-row bounded-read cap — see [Bounded reads](#bounded-reads-no-pagination) |
| 429 | `RATE_LIMITED` | Bucket exceeded — see [Rate limits](/limits/rate-limits) |
| 503 | `DECRYPT_FAILED` | Vault couldn't decrypt one of the values in this pull |
| 503 | `CONFIG_ERROR` | Server-side misconfiguration, not your key |
| 500 | `INTERNAL_ERROR` | Unrecognized failure |
```bash
curl -i "https://www.envpilot.dev/api/v1/projects/backend/variables?environment=production" \
-H "Authorization: Bearer envpk_a_revoked_key"
# HTTP/1.1 401 Unauthorized
# {"error":"Invalid or revoked API key","code":"INVALID_KEY"}
```
## Bounded reads, no pagination
There's no cursor or page parameter on any read endpoint — `keys`, `prefix`, and `metadata_only` are filters, not pagination. A pull is **all-or-nothing up to 1000 active rows** per project (variables and accounts each). If a project has more than that, the request fails outright with `422 OVERFLOW` and a message telling you to contact support to raise the limit — you will never get a silently-truncated partial page back.
The same "loud, not partial" rule applies to decryption: if any single value in the pull fails to decrypt, the **whole request fails** with `503 DECRYPT_FAILED` naming the offending key. Nothing decrypted so far is returned — Envpilot would rather fail a pull than hand back a response with one variable silently missing.
## Retrying
Envpilot's public API fails **closed**: every denial and every partial-data risk turns into a full request failure with a specific code, not a degraded response. That makes the retry decision mechanical:
- **429 `RATE_LIMITED`** — respect the `Retry-After` header (seconds) before retrying the identical request. See [Rate limits](/limits/rate-limits) for the per-bucket windows.
- **503 `DECRYPT_FAILED`** — retryable; vault errors are usually transient. If it persists across retries, the variable needs to be re-saved in Envpilot rather than pulled again.
- **503 `CONFIG_ERROR` / 500 `INTERNAL_ERROR`** — retryable with backoff; these are server-side, not caused by your request.
- **401 / 403 / 404 / 422** — not retryable as-is. These only change if you fix the actual cause: replace a revoked key, create a new key with the required immutable scope or surface, or shrink the project below the 1000-row cap.
## Next steps
- [API Reference](/api/overview) — every endpoint, filter, and error code
- [Architecture](/start/architecture) — the five client surfaces and the one auth core
- [MCP Server](/mcp/overview) — create the right MCP key, configure `ENVPILOT_API_KEY`, and connect an AI agent directly to your variables
- [Rate limits](/limits/rate-limits) — every per-key bucket and what happens when you exceed it
- [API Security](/api/authentication) — key model, scoping, revocation, and audit
## Limits
- Read-only. Every endpoint, every surface.
- Pro plan (`public_api`), and the gate is re-checked on every call.
- 120 requests/min for metadata, 30/min for value pulls, per key.
- No pagination: a response that cannot be complete is an error, not a page.
- No CORS — keys belong on a server, never in a browser bundle.
========================================================================
AUTHENTICATION
========================================================================
Source: https://docs.envpilot.dev/api/authentication
# Authentication
The REST API, MCP server, and GitHub Action all authenticate with the **same key system** and call the **same** underlying Convex actions — one enforcement core, three faces. Nothing about auth, scoping, or auditing is re-implemented per surface.
## Key model
- A new key is generated as `envpk_...` and shown **exactly once**, at creation time.
- Envpilot stores only a **SHA-256 hash** of the key, never the plaintext. If you lose a key, there's no way to recover it — revoke it and issue a new one.
- Every key is scoped along three dimensions: **projects** (all or a specific list), **environments** (all or a specific list), and **resources** (`variables`, `accounts`, `projects`, optionally `files`, and optionally `requests`). A request for anything outside a key's scope is treated as if it doesn't exist.
- Every key also declares its **surfaces** — which faces it may authenticate on: `rest_api`, `mcp_server`, `github_action`. All three route through one enforcement core (`_authorizeRequest`); see [Architecture](/start/architecture).
- Keys can optionally have an **expiry date** (`expiresAt`). An expired key returns the same uniform "invalid or revoked" error as a bad or revoked key — the API never tells a caller _why_ a key stopped working.
## The `files` resource is never granted by default
[Secret files](/platform/secret-files) are the highest-value material a key can read — a signing keystore is not recoverable by rotating a string. So `files` is an explicit opt-in when minting a key, never included by a "select all" default, and every file-content read is audited individually.
## Machine credentials never write secrets
Every machine credential — REST API, MCP server, or GitHub Action key — is fundamentally **read-only**. No API key can create, edit, or delete a variable's value. The blast radius of a leaked key is bounded to reads within its scope, never a silent change to what a deploy pulls.
There is exactly one escalation path, and it's **opt-in per key**: a key whose scope includes the `requests` resource can **file a variable request** — an ask, with a required justification, that lands in a human reviewer's dashboard queue. This is not a write. The agent never proposes a value; if the reviewer approves, **the reviewer supplies the value**. Keys without the `requests` resource (including every GitHub Action key) can't file requests at all.
This is the trust model in one line: **read-only keys, human-approved escalation.** Request creation is additionally throttled per key — 5 per hour (burst 2), a standing cap of 5 open pending requests, and a 24-hour cooldown after a rejection — so a retry-looping agent can't flood reviewers. See [Rate limits](/limits/rate-limits) and [Architecture](/start/architecture).
## Revocation is immediate
Revoking a key from the dashboard takes effect on the **next request** — there is no grace period and no cache to wait out. Every request re-checks the key's status (valid, scoped, not expired, not revoked, plan still entitled) before doing any work.
## Why 401 vs 403 vs 404 are deliberately uninformative
- **401** covers every reason a key doesn't work — missing, malformed, invalid, expired, or revoked — as one message. This stops an attacker from using error responses to fingerprint _why_ a specific key failed.
- **403** means the key is valid but the org's plan or the key's own resource scope doesn't cover this call (e.g. a `variables`-only key hitting `/accounts`, or the org isn't on a plan with `public_api`/`mcp_server` enabled).
- **404** means the target doesn't exist **or** exists but is outside the key's scope — both return the same 404. A key is never allowed to confirm the existence of a project it can't see.
## Fail-loud, never partial
If any part of a variable pull can't be completed safely, the whole request fails instead of returning something silently incomplete:
- **No partial pulls.** If vault decryption fails for any variable in the response, the request aborts with `503` rather than returning the variables that did decrypt and omitting the ones that didn't.
- **No sentinel values.** A variable that fails to decrypt is never represented as an empty string, `null`, or placeholder — that would look like a real (wrong) value to a deploy script.
- **No silent truncation.** A project with more than 1000 matching variables returns `422` rather than a truncated list that looks complete but isn't.
## Audit trail
Every value pull is logged — which key, which project/environment, which filters were used, and the source (`public-api`, `mcp`, or `cicd` for the GitHub Action). Metadata-only reads (key names without values) are **not** audited individually to avoid log noise, since they carry no secret exposure.
Every **denied** request is also logged, including:
- Reuse of a revoked key
- A request outside a key's scope
- A tier/plan gate rejection after a downgrade
Revoked-key reuse is treated as the highest-signal event in this set — it's the strongest indicator of a leaked key still being used after the team responded.
## Rate limiting
Rate limits are enforced per key with a token-bucket limiter — 120/min for metadata reads, 30/min for value pulls (see [API Reference](/api/overview) for the full table). Requests using a key hash that doesn't match anything on file are rate-limited separately, per hash, to slow down brute-force key guessing.
## Why there's no CORS
The API and MCP endpoint deliberately do not send CORS headers, so browsers refuse cross-origin requests to them. API keys are bearer credentials with broad read access to your secrets — they must never be shipped in client-side JavaScript where anyone with dev tools open could read them out of a network request. If you need secrets in a browser context, that's a product decision to make deliberately, not something the public API should make easy by accident.
## CI runners are ephemeral
GitHub-hosted (and most self-hosted) runners are torn down after each job. A pulled variable only exists in that job's process environment for the duration of the run — it isn't persisted anywhere by Envpilot's side, and the [GitHub Action](/action/overview) additionally masks every value in the job log before it's exported.
## Tier downgrades
If an organization's plan is downgraded below the tier that grants `public_api`/`mcp_server`, existing keys aren't proactively deleted — the same per-request gate check simply starts returning `403` on the next call. There's no background sweep to race and no window where a downgraded org's keys keep working past the next request.
## Limits
- **Scope is immutable.** Projects, environments, resources and surfaces are fixed at creation.
- **The plaintext is shown once.** There is no recovery and no rotate-in-place — revoke and re-issue.
- **Org-wide key creation is owner-only**; team leads can mint keys limited to specific projects.
- **Pro plan required** (`public_api` / `mcp_server`), re-checked per request.
- Keys are bearer credentials with no IP allowlist and no per-key CORS exemption.
========================================================================
ERRORS
========================================================================
Source: https://docs.envpilot.dev/api/errors
# Errors
Every non-2xx response is:
```json
{ "error": "human-readable message", "code": "machine_code" }
```
plus an `x-request-id` header. Quote that id to support — it identifies the exact request without you having to send anything sensitive.
## Status codes
| Status | Meaning |
| ------ | ----------------------------------------------------------------------------------------------- |
| `400` | A required query parameter is missing or malformed |
| `401` | The key is missing, malformed, invalid, expired, or revoked — one uniform answer for all five |
| `403` | The key is valid, but its scope or the organization's plan does not cover this call |
| `404` | The target does not exist, **or** exists outside the key's scope — the same response either way |
| `422` | The response would exceed the bounded-read ceiling (1000 items) — refused rather than truncated |
| `429` | Rate limit exceeded. Carries `Retry-After` in **seconds** |
| `503` | A vault decrypt failed mid-request, or the service is misconfigured — the whole request aborts |
| `500` | Something unexpected. The `x-request-id` is the thing to report |
## Why 401, 403 and 404 are vague
- **401 never says why.** Missing, malformed, invalid, expired and revoked all read the same, so error responses cannot be used to fingerprint which key you hold or whether it once worked.
- **403 means valid but not allowed** — a `variables`-only key hitting `/accounts`, or an organization whose plan no longer includes `public_api`. The gate is re-checked per request, so a downgrade shows up on the next call.
- **404 covers both "no such thing" and "not yours."** A key can never confirm the existence of a project it cannot see.
## Retrying
| Status | Retry? |
| ------------ | ---------------------------------------------------------------------------- |
| `429` | Yes, after `Retry-After` seconds. Do not retry sooner; the bucket is empty |
| `503` | Yes, once or twice with backoff. If it persists, a secret needs re-uploading |
| `4xx` others | No. Retrying a scope or parameter error produces the same answer |
## The failures that look like success elsewhere
Two behaviours are worth building against explicitly:
- **`422` instead of a short list.** Other APIs paginate; this one refuses. A partial `.env` starts a process that then misbehaves far from the cause.
- **`503` instead of a null value.** A variable that cannot be decrypted is never returned as `""`, `null`, or a placeholder — those look like real values to a deploy script.
## See also
- [Rate limits](/limits/rate-limits)
- [Authentication](/api/authentication)
========================================================================
ORGANIZATION
========================================================================
Source: https://docs.envpilot.dev/api/organization
# Organization
Returns metadata for the organization the key belongs to. Works with **any** valid key regardless of resource scope — this is discovery-level information the key holder already has.
```bash
curl https://www.envpilot.dev/api/v1/organization \
-H "Authorization: Bearer envpk_..."
```
```json
{
"id": "org_abc123",
"name": "Acme Inc",
"slug": "acme",
"plan": "pro"
}
```
## Limits
- No parameters. There is nothing to filter.
- Counts against the metadata bucket (120/min per key).
- Not audited — no secret material is returned.
- A key cannot see any organization other than its own.
========================================================================
PROJECTS
========================================================================
Source: https://docs.envpilot.dev/api/projects
# Projects
## List projects
Every project in the key's scope, with resource counts. Projects outside scope are silently omitted — an empty match is `200` with an empty list, never an error.
```bash
curl https://www.envpilot.dev/api/v1/projects \
-H "Authorization: Bearer envpk_..."
```
```json
{
"projects": [
{
"id": "proj_1",
"name": "Backend",
"slug": "backend",
"variableCount": 42,
"accountCount": 3
}
]
}
```
## Get one project
```bash
curl https://www.envpilot.dev/api/v1/projects/backend \
-H "Authorization: Bearer envpk_..."
```
A slug that does not exist and a slug outside the key's scope both return `404`. That is deliberate: a key must not be able to confirm the existence of a project it cannot read.
## Limits
- Requires the `projects` resource.
- Metadata bucket: 120 requests/min per key.
- Counts are of **active** resources; trashed items are not included.
- No pagination — see [bounded reads](/api/overview).
## Next
- [Variables](/api/variables) · [Accounts](/api/accounts) · [Files](/api/files)
========================================================================
VARIABLES
========================================================================
Source: https://docs.envpilot.dev/api/variables
# Variables
## Parameters
| Query param | Required | What it does |
| --------------- | --------------------------- | -------------------------------------------------------------------- |
| `environment` | Yes, unless `metadata_only` | `development`, `staging`, or `production` |
| `keys` | No | Comma-separated exact key names — `keys=A,B,C` |
| `prefix` | No | Only keys starting with this prefix — `prefix=NEXT_PUBLIC_` |
| `metadata_only` | No | `true` returns key names only: no decrypt, no values, no audit entry |
| `format` | No | `json` (default) or `env` for dotenv text |
```bash
curl "https://www.envpilot.dev/api/v1/projects/backend/variables?environment=production&prefix=NEXT_PUBLIC_" \
-H "Authorization: Bearer envpk_..."
```
```json
{
"environment": "production",
"variables": [
{
"key": "NEXT_PUBLIC_API_URL",
"value": "https://api.acme.com",
"updatedAt": 1752192000000
}
]
}
```
With `format=env` the same response is dotenv text, ready to redirect into a file.
## Filter before you pull
`keys` and `prefix` are not conveniences — they narrow what gets decrypted, which narrows what appears in the audit log and what a compromised process could scrape. A build that needs three variables should ask for three.
`metadata_only=true` is the right call whenever you are checking _whether_ something exists: it never touches the vault, uses the cheaper rate bucket, and is not audited individually.
## Legacy endpoint
The original single-project endpoint used by the [GitHub Action](/action/overview). It still works and is not going anywhere, but new integrations should use `/v1/projects/{slug}/variables`.
## Limits
- Requires the `variables` resource, and the project **and** environment must be in the key's scope.
- **Rate**: metadata reads use the 120/min bucket; value pulls use the 30/min bucket.
- **`422`** if more than 1000 variables match — refused, never truncated.
- **`503`** if any variable in the response fails to decrypt — the whole request aborts, with no partial result and no sentinel values.
- Values are current. There is no version-history endpoint; history lives in the dashboard.
- Read-only. There is no write endpoint at any version.
## Next
- [Accounts](/api/accounts) · [Files](/api/files) · [Errors](/api/errors)
========================================================================
SHARED ACCOUNTS
========================================================================
Source: https://docs.envpilot.dev/api/accounts
# Shared accounts
[Shared accounts](/platform/shared-accounts) are credentials for a third-party service that a team shares — distinct from variables, which are configuration your app reads.
## Parameters
| Query param | Required | What it does |
| --------------- | -------- | --------------------------------------------------- |
| `environment` | No | Filter to one environment |
| `metadata_only` | No | `true` returns account names only, no secret values |
```bash
curl "https://www.envpilot.dev/api/v1/projects/backend/accounts?environment=production" \
-H "Authorization: Bearer envpk_..."
```
## Limits
- Requires the `accounts` resource; the project must be in the key's scope.
- **Rate**: metadata reads use the 120/min bucket; value pulls use the 30/min bucket.
- Value pulls are audited against the key.
- Read-only — accounts are created and rotated by humans in the dashboard.
- The same bounded-read and fail-loud rules apply as for [variables](/api/variables).
========================================================================
SECRET FILES
========================================================================
Source: https://docs.envpilot.dev/api/files
# Secret files
Project-scoped, unlike the variable endpoints: `project` is a **required query parameter** even when the key's scope resolves a single project.
## Parameters
| Query param | Required | What it does |
| -------------- | -------- | -------------------------------------------------------------------- |
| `project` | Yes | Project slug |
| `environment` | Yes | `development`, `staging`, or `production` |
| `metadataOnly` | No | `1` or `true` — path, size, mode and checksum with nothing decrypted |
| `path` | No | Repeatable. Restrict to these exact destination paths |
```bash
# What does this project need, and does my copy match?
curl "https://www.envpilot.dev/api/v1/files?project=mobile-app&environment=production&metadataOnly=1" \
-H "Authorization: Bearer envpk_..."
# Fetch exactly one file's contents
curl "https://www.envpilot.dev/api/v1/files?project=mobile-app&environment=production&path=android/app/upload.jks" \
-H "Authorization: Bearer envpk_..."
```
## Response
```json
{
"project": { "slug": "mobile-app" },
"environment": "production",
"files": [
{
"name": "Play upload keystore",
"path": "android/app/upload.jks",
"mode": "0600",
"size": 2842,
"sha256": "9f2b…",
"contentType": "application/octet-stream",
"environments": ["production"],
"updatedAt": 1752192000000,
"content": "MIIK…"
}
]
}
```
`content` is base64 and is **omitted entirely** when `metadataOnly` is set. `sha256` is of the plaintext, so you can diff a local copy without fetching anything.
## Writing them out
Write each file to its recorded `path`, relative to your workspace root, with its recorded `mode` (`0600` or `0400`). Re-validate containment yourself — refuse absolute paths, refuse `..`, refuse writing through a symlink. The [GitHub Action](/action/secret-files) does exactly this and is worth reading as a reference implementation.
## Limits
- Requires the **`files` resource**, which is never granted by default.
- Content fetches are **audited** individually; `metadataOnly` requests are not.
- **Rate**: content reads refill at 60/min with a burst of 1000 (the per-project file ceiling); metadata reads use the 120/min bucket.
- A single request refuses to return more than **8 MiB** of file content — batch by size, as the Action does.
- **`422`** if the project holds more files than a complete listing allows — refused, never truncated.
- **`503`** if any requested file fails to decrypt.
- Read-only. Uploading is a human action in the CLI or dashboard.
## Next
- [Secret files](/platform/secret-files) — paths, modes, storage
- [GitHub Action: secret files](/action/secret-files)
========================================================================
MCP OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/mcp/overview
# MCP overview
MCP (Model Context Protocol) lets an assistant call tools against your real data instead of guessing at it. Envpilot runs a remote MCP server, so Claude Code, Codex, or Cursor can look up your projects, read the variables you allow, and fetch a secret file a build needs — through the **same** auth, scope, rate limits and audit trail as the REST API. Nothing is re-implemented for agents.
Requires the **Pro** plan (`mcp_server`) and an [API key](/mcp/setup).
```
https://www.envpilot.dev/api/mcp
```
Transport is Streamable HTTP; auth is `Authorization: Bearer envpk_…`.
## What an agent can do
| Can | Cannot |
| ----------------------------------------------------- | ----------------------------------------------- |
| List projects in the key's scope | Create, edit, or delete anything |
| Read variables and shared accounts | Read outside the key's projects or environments |
| Read secret files, with the `files` resource | Approve its own request |
| Search project names and variable **keys** | See variable **values** through search |
| File a variable request, with the `requests` resource | Propose a value for that request |
That last row is the whole trust model: **read-only keys, human-approved escalation.** An agent states what it needs and why; a human decides and supplies the value. See [Agent requests](/mcp/agent-requests).
## Getting connected
1. [Mint a scoped key](/mcp/setup) and put it in the client's environment.
2. [Register the server](/mcp/clients) in Claude Code, Codex, Cursor, or any Streamable HTTP client.
3. Confirm with `/mcp` that the **tools** loaded, not merely that the server is listed.
## Limits
- **Pro plan only**, re-checked on every call — a downgrade stops the agent immediately.
- **Scope is immutable.** Projects, environments, resources and surfaces are fixed at creation; widening means a new key.
- **Rate limits are shared with REST** for that key: 120/min metadata, 30/min value pulls, plus the file buckets. Request filing is 5/hour, burst 2.
- **Every value-returning call is audited** against the key, including every secret-file fetch.
- **Hosted Claude connectors are not supported** — they expect OAuth, and Envpilot uses a fixed bearer key. Use Claude Code.
## Next
- [Setup](/mcp/setup) · [Clients](/mcp/clients) · [Tools](/mcp/tools) · [Agent requests](/mcp/agent-requests)
- [Agent workflow guide](/guides/agents)
========================================================================
MCP SETUP
========================================================================
Source: https://docs.envpilot.dev/mcp/setup
# MCP setup
## Create an MCP key
Create a dedicated key in **Organization → Settings → API Keys → New API Key**:
1. Give the key a recognizable name, such as `codex-development`.
2. Select the **MCP server** surface. Deselect REST API and GitHub Action unless this same credential genuinely needs them.
3. Select only the projects the client needs. **All projects** also includes projects created in the future and is owner-only.
4. Select only the environments the client needs. A development-only agent should not receive production access.
5. Select the resources required by its tools:
| Resource | MCP tools enabled |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `projects` | `envpilot_list_projects`; project-name and project-slug matches from `envpilot_search` |
| `variables` | `envpilot_get_variables`, `envpilot_get_variable`; variable-key matches from `envpilot_search` (which also needs `projects` to enumerate) |
| `accounts` | `envpilot_list_accounts` |
| `files` | `envpilot_list_files`, `envpilot_get_file` |
| `requests` | `envpilot_request_variable`, `envpilot_get_request_status` |
| `docs` | `envpilot_search_docs`, `envpilot_get_doc`, `envpilot_create_doc`. **Cannot be combined with `files`** — see below |
For the common project-discovery and variable-read workflow, select `projects` and `variables`. Most assistants begin with `envpilot_list_projects`; without `projects`, that opening call is denied. An accounts-only or requests-only key is still valid, but only for the corresponding tools.
6. Choose an expiry, create the key, and copy the `envpk_...` value immediately.
`docs` and `files` are mutually exclusive: a key may carry one or the other,
never both. Documentation is prose an agent reads into its context and `files`
returns decrypted key material, so a single credential holding both is an
exfiltration chain. An agent that needs each gets two keys.
Project, environment, resource, and surface scopes are **immutable**. You cannot widen or edit them after creation; create a replacement key when the required access changes. The plaintext is also displayed **once**: Envpilot stores only its SHA-256 hash and cannot recover a lost key.
## Endpoint
```
https://www.envpilot.dev/api/mcp
```
Transport is Streamable HTTP (the current MCP spec transport for remote servers). Auth is a plain bearer token — the same `envpk_...` API key you'd use for the REST API:
```
Authorization: Bearer envpk_your_key_here
```
## Set ENVPILOT_API_KEY
Keep the plaintext out of MCP config files and repositories. Put it in the environment of the process that starts the client, then make the client reference the variable by name.
### macOS
For the current Terminal session (zsh), enter the key at the hidden prompt so it does not appear in shell history:
```bash
read -rs "ENVPILOT_API_KEY?Envpilot API key: "; echo
export ENVPILOT_API_KEY
```
This value disappears when the shell exits. For future terminal sessions, have a password manager inject it or edit a user-only shell startup file (do not build the line with a shell command that would record the key in history):
```bash
# ~/.zshrc
export ENVPILOT_API_KEY='envpk_your_key_here'
```
A literal export in a startup file is persistent but is plaintext on disk; never put it in a repository, and restrict the file to your user:
```bash
chmod 600 ~/.zshrc
```
Apps opened from Finder, the Dock, or Spotlight do not inherit variables from `~/.zshrc`. To make the current login session pass the already-exported value to **newly launched** GUI apps:
```bash
launchctl setenv ENVPILOT_API_KEY "$ENVPILOT_API_KEY"
```
Fully quit and reopen Codex, Cursor, or the IDE afterward. `launchctl setenv` lasts only for the current login session; after logout or restart, set it again (preferably from a login automation that reads a keychain or password manager—do not embed the raw key in a LaunchAgent plist). Do not run `launchctl getenv ENVPILOT_API_KEY` without redirecting it, because that prints the secret.
### Linux
For the current bash session:
```bash
read -rsp "Envpilot API key: " ENVPILOT_API_KEY; echo
export ENVPILOT_API_KEY
```
For future terminal sessions, have a secret manager inject it or edit the appropriate user-only login file (do not build the line with a shell command that would record the key in history):
```bash
# ~/.profile
export ENVPILOT_API_KEY='envpk_your_key_here'
```
A literal export is stored as plaintext, so keep the file private:
```bash
chmod 600 ~/.profile
```
A desktop launcher inherits the graphical login session, not an interactive shell's `.bashrc`. The simplest safe option is to launch the app from a terminal that already has the variable. On systemd-based desktops, you can also import the current value into the user and D-Bus activation environments:
```bash
systemctl --user import-environment ENVPILOT_API_KEY
dbus-update-activation-environment --systemd ENVPILOT_API_KEY
```
Then fully quit and reopen the app. For persistence, configure the variable through your desktop login environment or secret manager and sign out and back in; do not put the raw key in a `.desktop` launcher.
### Windows PowerShell
Set the key for the current PowerShell process without echoing the input:
```powershell
$secret = Read-Host "Envpilot API key" -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret)
try {
$env:ENVPILOT_API_KEY = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
Remove-Variable secret, pointer
```
Child processes launched from that PowerShell window inherit it. To persist the current value for your Windows user:
```powershell
[Environment]::SetEnvironmentVariable(
"ENVPILOT_API_KEY",
$env:ENVPILOT_API_KEY,
"User"
)
```
The User-scope value is stored in your Windows profile and is available only to **new** processes. Fully quit and reopen terminals, Codex, Cursor, and IDE windows; already-running processes keep their old environment.
### WSL
Windows and WSL have separate process environments. A Windows User-scope variable does not automatically configure clients running inside WSL, and a variable exported in WSL does not configure Windows GUI apps. Set `ENVPILOT_API_KEY` separately inside the WSL distribution using the Linux instructions, then start the WSL-hosted client from a new WSL shell.
### Check presence without printing the key
Use a boolean check—never `echo`, `printenv`, `Get-ChildItem Env:`, or an unredirected `launchctl getenv` for a secret:
```bash
if [ -n "${ENVPILOT_API_KEY:-}" ]; then
echo "ENVPILOT_API_KEY is set"
else
echo "ENVPILOT_API_KEY is not set"
fi
```
```powershell
if ([string]::IsNullOrEmpty($env:ENVPILOT_API_KEY)) {
"ENVPILOT_API_KEY is not set"
} else {
"ENVPILOT_API_KEY is set"
}
```
A repository `.env` or `.env.local` file does **not** automatically configure an MCP client. Those files are conventions loaded only by frameworks or commands that explicitly read them. Codex, Claude Code, Cursor, and desktop launchers inspect their own process environment; they do not source arbitrary repository dotenv files. Keeping the MCP credential outside the repository also prevents an agent from reading the very key that controls its access.
## Rotating or updating a key
A key's projects, environments, resources, and surfaces are fixed at creation. To recover a lost key, rotate a credential, or grant different access (for example, adding the `projects` resource or MCP surface), create a new key with the complete intended scope.
1. Store the new value in the same environment-variable source. If you set both a persistent value and a current-session value, update both—an already-running shell or GUI process keeps the old value.
2. For macOS GUI clients, update `launchctl`; for Linux desktop clients, update the login/user environment; for Windows, update the User-scope value. WSL remains separate.
3. Fully quit and restart the client. The saved Claude Code, Codex, and Cursor configurations still reference the same variable name and need no secret edit.
4. Confirm `/mcp` or the client's MCP panel lists the Envpilot tools, then make a permitted tool call.
5. Revoke the old key in **Organization → Settings → API Keys**.
Revocation is instant—anything still using the old key stops on its next call. If you previously pasted a key directly into Claude Code, remove and re-add the server (`claude mcp remove envpilot`) or edit the saved entry; `claude mcp add` does not overwrite an existing server name.
## Limits
- **Scope cannot be widened.** Adding a project, environment or resource means a new key and a client restart.
- **The key is shown once.** Store it in an environment variable, never in a config file you commit.
- **Pro plan** (`mcp_server`), re-checked on every tool call.
- Hosted Claude connectors cannot carry a fixed bearer key — see [Connecting a client](/mcp/clients).
========================================================================
CONNECTING A CLIENT
========================================================================
Source: https://docs.envpilot.dev/mcp/clients
# Connecting a client
Set `ENVPILOT_API_KEY` in the client's environment first, using the instructions above.
### Claude Code
Reference the environment variable in an expanded `Authorization` header:
```bash
claude mcp add --transport http --scope user \
envpilot https://www.envpilot.dev/api/mcp \
--header 'Authorization: Bearer ${ENVPILOT_API_KEY}'
```
The **single quotes matter** in POSIX shells: they keep `${ENVPILOT_API_KEY}` literal in the saved configuration, and Claude Code expands it from its environment at connection time. The secret never lands in the file.
Choose the Claude Code configuration scope deliberately:
- `--scope local` (the default) keeps the server private to you in the current project.
- `--scope project` writes `.mcp.json` for the team. The environment-variable reference is safe to commit, but each user must set their own key and approve/trust the project server before tools load.
- `--scope user` keeps the server private to your user and makes it available across projects.
`claude mcp list` confirms the server is **registered** — a revoked or wrong key still shows as listed. To verify the connection is actually live and its tools loaded, run `/mcp` inside a session. Manage it later with `claude mcp get envpilot` / `claude mcp remove envpilot`.
### Codex CLI, desktop, and IDE extension
Codex's local CLI, desktop app, and IDE extension share the same MCP configuration layers. Register the server from the CLI:
```bash
codex mcp add envpilot --url https://www.envpilot.dev/api/mcp \
--bearer-token-env-var ENVPILOT_API_KEY
```
Or add it to the user-level `~/.codex/config.toml`:
```toml
[mcp_servers.envpilot]
url = "https://www.envpilot.dev/api/mcp"
bearer_token_env_var = "ENVPILOT_API_KEY"
```
The `url` selects Streamable HTTP, while `bearer_token_env_var` makes Codex build the bearer header from the named variable. Do not replace it with the plaintext key.
You may put the same block in `.codex/config.toml` to scope it to a repository, but Codex loads project configuration only after you trust that project. After changing the variable or configuration, start a new CLI session, select **Restart** for the server in Codex desktop, or restart the IDE extension. Run `/mcp` to confirm the server is connected and the Envpilot tools—not merely the saved server entry—are present.
### Cursor
Add a remote server to the global `~/.cursor/mcp.json`, or to `.cursor/mcp.json` for a trusted project:
```json
{
"mcpServers": {
"envpilot": {
"url": "https://www.envpilot.dev/api/mcp",
"headers": {
"Authorization": "Bearer ${env:ENVPILOT_API_KEY}"
}
}
}
}
```
Cursor resolves `${env:ENVPILOT_API_KEY}` from the environment it inherited at startup. Fully quit and reopen Cursor after setting or rotating the variable, then confirm the tools under MCP settings. Do not commit a raw `envpk_...` value.
### Other Streamable HTTP clients
Configure the endpoint as Streamable HTTP and send this header on every request:
```text
Authorization: Bearer
```
Prefer a client-native secret store or environment-variable reference. Placeholder syntax is client-specific: do not assume a generic client expands `${ENVPILOT_API_KEY}` or `${env:ENVPILOT_API_KEY}`.
### Hosted Claude connectors are not compatible
Claude's hosted custom connectors—used by Claude web and synced to hosted Claude Desktop and mobile—are different from Claude Code's local MCP configuration. Their standard setup accepts a remote URL and an OAuth connection flow, not a per-user arbitrary `Authorization` header. Envpilot currently uses a fixed `envpk_...` bearer API key and does not expose the OAuth flow those hosted connectors expect, so use **Claude Code** instead.
Do not paste an Envpilot API key into an **OAuth Client Secret** field. That field authenticates an OAuth client during a token exchange; it does not become `Authorization: Bearer envpk_...` on MCP requests and cannot adapt Envpilot's API-key auth into OAuth.
## Troubleshooting
A failed MCP tool call returns an error message in its result — no separate error code, since tool clients only see the text. The table lists each message (tool-specific variants may append detail, e.g. the request tools' `— filing requests needs the "requests" resource`). One exception: a missing or malformed `Authorization` header is rejected at the transport level with HTTP 401 before any tool runs.
First separate client setup failures from Envpilot tool errors:
| Symptom | Meaning / fix |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Server is registered but has no tools | Registration only proves the config was saved. Open `/mcp` or the client's MCP panel and inspect initialization. The most common cause is that the client process did not inherit `ENVPILOT_API_KEY`; set it in the parent environment and fully restart the client. |
| Works in a terminal, not a GUI app | The terminal has a current-session variable, but the desktop launcher started from a different environment. Use `launchctl` on macOS, the graphical login/user environment on Linux, or Windows User scope, then launch a new process. |
| Works on Windows but not in WSL | Windows and WSL environments are separate. Set the key inside the WSL distribution and restart the WSL-hosted client. |
| Header contains `${...}` literally | The client did not expand the placeholder. Claude Code uses `${ENVPILOT_API_KEY}`; Cursor uses `${env:ENVPILOT_API_KEY}`; Codex uses `bearer_token_env_var = "ENVPILOT_API_KEY"`. Confirm the client supports that syntax and inherited the variable, then restart it. |
| HTTP 401 before tools load | Ensure the header is exactly `Authorization: Bearer envpk_...`: one `Bearer` prefix, one space, no surrounding quotes in the value, no duplicated prefix, and no literal placeholder. Use a safe presence check instead of printing the key. |
| Hosted Claude cannot authenticate | Hosted Claude web/Desktop/mobile custom connectors use their URL/OAuth flow and cannot supply Envpilot's fixed bearer key through the standard setup. Use Claude Code; do not put the API key in an OAuth client-secret field. |
Once tools load, these are the server's error semantics:
| Message | Meaning / fix |
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Invalid or revoked API key` | The key is wrong, revoked, or expired—create a replacement key. |
| `That resource is not in this API key's scope` | The key is missing the resource a tool needs. Scopes are immutable: use the [Tools](/mcp/tools) table to create a replacement with the required resource. Request tools append `— filing requests needs the "requests" resource`. |
| `That environment is not in this API key's scope` | The key is limited to other environments. Use one already in scope, or create a replacement key that includes the required environment. |
| `Project not found` | The slug is wrong, or the project is outside the key's immutable scope—the two are indistinguishable by design; see [Understand scope](/api/quickstart#understand-scope). |
| `This API key is not enabled for this surface` | The key was created without the MCP server surface. Surfaces are immutable: create a replacement with **MCP server** selected. |
| `The public API is available on the Pro plan — this organization's plan no longer includes it` | The organization's plan no longer includes MCP access. |
| `Rate limit exceeded — retry after Nms` | Hit a rate-limit bucket: 120/min metadata or 30/min value pulls (both shared with REST), or 5/hour (burst 2) for `envpilot_request_variable`—back off for the given delay. |
| `Missing or invalid API key.` (HTTP 401) | Transport-level rejection before any tool runs—the `Authorization` header is missing or malformed. It must resolve to `Bearer envpk_...`, not a literal placeholder. |
Older server builds returned an opaque `[Request ID] Server Error` for all of the above instead of the real message — if you see that instead of one of these strings, the connected server predates this fix.
## Limits
- Streamable HTTP only. There is no stdio transport and no local proxy.
- Placeholder syntax is client-specific — `${VAR}`, `${env:VAR}` and `bearer_token_env_var` are not interchangeable.
- A client only inherits the environment it was launched with; changing the key means fully restarting it.
- Hosted Claude web, Desktop and mobile connectors expect OAuth and cannot authenticate here. Use Claude Code.
========================================================================
TOOLS
========================================================================
Source: https://docs.envpilot.dev/mcp/tools
# Tools
Every tool runs against the same scope, rate limits and plan gate as the REST API, and every value-returning call is audited.
## Read tools
| Tool | What it does | Resource |
| ------------------------ | ----------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `envpilot_list_projects` | List every project in the key's scope | `projects` |
| `envpilot_get_variables` | Variables for a project + environment, with optional `keys` / `prefix` / `metadata_only` | `variables` |
| `envpilot_get_variable` | One variable by exact key | `variables` |
| `envpilot_list_accounts` | Shared accounts for a project, optionally filtered by environment | `accounts` |
| `envpilot_list_files` | Secret-file **metadata** for a project: path, size, mode, checksum. Never contents | `files` |
| `envpilot_get_file` | The decrypted contents of **one** secret file, base64-encoded | `files` |
| `envpilot_search` | Search project names/slugs and variable **keys** — never values. Bounded to 20 projects / 100 matches | `projects` (plus `variables` for key matches) |
| `envpilot_search_docs` | Full-text search over **published** documentation — titles and page bodies. Never returns drafts | `docs` |
| `envpilot_get_doc` | One **published** documentation page with its markdown body | `docs` |
## Write-adjacent tools
A machine credential can perform exactly two mutations, and both end at a human:
| Tool | What it does | Resource |
| ----------------------------- | ---------------------------------------------------------------------------------------------------- | ---------- |
| `envpilot_request_variable` | File a request with a **required justification**. A human approves it and supplies the value | `requests` |
| `envpilot_get_request_status` | Poll a filed request — `pending`, `approved`, `rejected` (with the reviewer's reason), or `canceled` | `requests` |
| `envpilot_create_doc` | Propose a documentation page. Always creates a **draft**; a human publishes it | `docs` |
Neither writes anything a reader sees on its own. A requested variable has no
value until a person supplies one, and a proposed page is invisible to your
team — and to every other agent — until a person publishes it.
## Secret file tools in detail
### `envpilot_list_files`
| Parameter | Required | Notes |
| ------------- | -------- | ------------------------------------------------------ |
| `project` | Yes | Project slug |
| `environment` | No | Omit to return files across every in-scope environment |
Metadata only: destination path, size, mode, checksum, environments. Nothing is decrypted and no download is recorded, so an agent can safely explore what a build needs.
### `envpilot_get_file`
| Parameter | Required | Notes |
| ------------- | -------------------------------------------- | ------------------------------------------------- |
| `project` | Yes | Project slug |
| `path` | Yes | Exact destination path from `envpilot_list_files` |
| `environment` | When the path exists in several environments | Otherwise the call fails rather than guessing |
Returns real secret material. The tool description tells the agent as much: fetch only the file the task needs, never speculatively, never echo the contents back to the user, and write straight to the recorded path.
If the path exists in more than one environment and no `environment` was given, the call fails with a message naming the count. Guessing which one a build wanted would be worse than asking.
## Documentation tools in detail
### Drafts are invisible
`envpilot_create_doc` always writes a draft. Nothing on this surface can
publish, and `envpilot_search_docs` / `envpilot_get_doc` return **published
pages only** — a draft is not merely hidden from search, it cannot be fetched
by id either. That gate is what stops a page one agent wrote from reaching
another agent's context before a person has read it.
### Page count is capped by plan
`envpilot_create_doc` fails once the project or the organization is at its
page limit — 10 per project and 25 per organization on Free, unlimited on Pro.
The caps are the same numbers the dashboard enforces, so an agent cannot use
the MCP surface to walk around them. See [Plans & Limits](/limits/plans).
### Pages name variables, never values
A page may reference `API_BASE_URL`; it never contains the value. Resolving it
is the reader's own `envpilot_get_variables` call under its own `variables` and
environment scope, so reading documentation can never become reading secrets.
### `docs` and `files` cannot share a key
A key carrying `docs` may not also carry `files`, and the dashboard refuses to
mint one. Documentation is prose an agent pulls into its context; `files`
returns decrypted keystores and SSH keys. On one credential those two are an
exfiltration chain, so they are kept apart at mint time. Use two keys.
### Content is scanned on write
Every proposed page is checked before it is stored. Credential shapes (PEM
blocks, provider key prefixes, connection strings with inline passwords) are
rejected outright, and text instructing an agent to call a tool is refused.
High-entropy strings that are normal in API documentation — sample JWTs, base64
payloads, commit SHAs — pass through untouched.
## Cost of each call
| Tool | Bucket | Audited |
| ------------------------------------------------------- | ------------------------------------- | ------- |
| `list_projects`, `search`, `list_files`, metadata reads | 120/min metadata | No |
| `get_variables`, `get_variable`, `list_accounts` | 30/min value pulls | Yes |
| `get_file` | file reads: refill 60/min, burst 1000 | Yes |
| `request_variable` | 5/hour per key, burst 2 | Yes |
| `search_docs`, `get_doc` | 120/min metadata | No |
| `create_doc` | 30/hour per key, burst 10 | Yes |
## Limits
- A tool whose resource is missing from the key is denied with a message naming the resource — scopes are immutable, so the fix is a replacement key.
- A project outside scope behaves exactly as if it does not exist.
- `envpilot_search` never returns values, only names and keys.
- There is no tool that writes a variable, uploads a file, publishes a documentation page, or approves a request. There is no plan on which one appears.
- Documentation search is full-text: whole words, with only the last term prefix-matched. It is not a substring match and does not correct typos.
## Next
- [Agent requests](/mcp/agent-requests) · [Setup](/mcp/setup)
========================================================================
AGENT REQUESTS
========================================================================
Source: https://docs.envpilot.dev/mcp/agent-requests
# Agent requests
A machine credential can never write a secret value. The one thing it can do beyond reading is **ask** — and only if its key carries the `requests` resource. `envpilot_request_variable` takes the variable key and a required justification, and files a request into a human reviewer's dashboard queue. The agent never proposes a value; if the reviewer approves, **the reviewer supplies it**.
The loop for an agent is:
1. Call `envpilot_request_variable` with the key and a justification explaining why it's needed.
2. Poll `envpilot_get_request_status` until it returns `approved` or `rejected`.
3. On `approved`, read the value with `envpilot_get_variable`. On `rejected`, the status carries the reviewer's reason — report it back rather than retrying blindly.
Request creation is deliberately strict: it is rate-limited to 5 per hour per key (burst 2), capped at 5 open pending requests per key, and a rejected request for a given variable can't be re-filed for 24 hours. Every created request emails a reviewer, so these limits stop an agent loop from becoming alert spam. See [Rate limits](/limits/rate-limits) for the full picture and [Architecture](/start/architecture) for how "agent requests, human approves" fits the wider trust model.
## Reviewing on the human side
A reviewer sees machine-filed requests in the same queue as human ones — the project's Requests inbox, `envpilot requests`, or an email. Approving one prompts for the value:
```bash
envpilot requests # find the id
printf %s "$SECRET" | envpilot requests approve --value-stdin
```
Read the justification before approving. It is the only account of what the agent was doing when it asked, and it is required precisely so that a reviewer never has to guess.
## Limits
- **Requests create variables.** An agent cannot request a change to an existing value.
- **5 filings per hour per key**, burst 2, and at most **5 open pending** at a time. The sixth is refused until a human clears one.
- **24-hour cooldown after a rejection** for the same key. A rejection is a decision.
- The GitHub Action can never file a request, whatever its scope.
## See also
- [Requests & approvals](/platform/requests) — the full model
- [CLI: requests](/cli/requests) — the reviewer's side
========================================================================
DASHBOARD OVERVIEW
========================================================================
Source: https://docs.envpilot.dev/dashboard/overview
# Dashboard overview
The dashboard at [envpilot.dev](https://www.envpilot.dev) is the full management surface. Sign in through WorkOS AuthKit (Google, GitHub, or email), then use the sidebar to move between projects, team, audit trail, and settings.
## Home
`/dashboard` shows a status summary (project, variable, team and event counts), a **Getting Started** checklist for new organizations, **Recent Projects**, a **Recent Activity** feed linking into the audit log, and a **Team Members** panel.
Two widgets appear when your plan enables them:
- **Expiring Secrets** — variables with a [rotation schedule](/platform/rotation) expiring within 7 days.
- **Shared secrets** — a summary of your active [share links](/platform/sharing).
## Projects list
`/dashboard/projects` lists every project you can reach. **Create Project** offers a framework template (Next.js, T3 Stack, Django, Rails, and around twenty more) that pre-fills a starter variable set with placeholders.
## Audit log
`/dashboard/audit` is a searchable log of every recorded action — variable reads and writes, secret-file downloads, permission changes, authentication, team changes, billing and CI/CD events, across 14 categories.
Filter by free text, category, and date range (24h / 7d / 30d / 90d), then export the filtered result as CSV or JSON.
Retention follows your plan: **7 days on Free, 365 on Pro** (`audit_log_retention_days`).
## Analytics
`/dashboard/analytics` (Project Manager and above) shows six panels: activity overview, project activity, variable changes by project, team activity, resource breakdown, and security insights — security-event, sensitive-access and permission-change counts.
The 7/30/90-day selector is capped by your plan's analytics retention (7 days Free, 30 Pro).
## Limits
- What you see is what your role allows; nothing hints at projects you cannot reach.
- Search across variables is server-side and capped at the first 100 matches, with a banner when you hit it.
- Analytics and audit ranges are truncated by tier retention, not just by the selector.
## Next
- [Working in a project](/dashboard/project)
- [Organization administration](/dashboard/organization)
========================================================================
WORKING IN A PROJECT
========================================================================
Source: https://docs.envpilot.dev/dashboard/project
# Working in a project
## The variable table
Opening a project lands on its variables, with tabs for **All** plus each of the three environments, a debounced server-side search, and a tag-filter row if tags are enabled.
Each row supports edit, delete, reveal, [version history with rollback](/platform/variables), and — when sharing is enabled — sending a [share link](/platform/sharing). Selected rows can be bulk-deleted from a floating action bar. Deleting warns that the variable is recoverable for **7 days**, after which it and its vault value are purged.
The create button reads **Add Variable** or **Request Variable** depending on your role. The same drawer has a **Bulk Paste** tab for a whole `.env` block.
Rotation, if your plan includes it, is a checkbox in the same form with presets of 30, 60, 90 (default), 180, or 365 days. See [Rotation & expiry](/platform/rotation).
## Requests
A project's requests list links through to the reviewer inbox at `/dashboard/requests`, tabbed by **Pending / Approved / Rejected / Canceled**. Only reviewers can act.
Before accepting, a reviewer picks which environments to approve for. A **machine-filed request** — from an API key over MCP — shows a masked input reading _"Enter the value to approve"_: the reviewer types the secret, and Accept stays disabled until both an environment and a value are supplied. The value is encrypted before the approval runs.
Rejection on this screen is a plain confirm dialog; the free-text reason field lives in the CLI's `requests reject --reason`.
## Files
Project → **Files** manages [secret files](/platform/secret-files): keystores, SSH keys, certificates, service-account JSON.
- **Upload** — drag and drop or pick a file, then set display name, destination path, environments, and mode (`0600 — owner read/write` or `0400 — owner read-only`).
- **Replace contents** — swap the bytes while metadata stays locked, so a rotated keystore keeps its path and grants.
- **Permissions** — per-file grants, the same model variables use.
- **Trash** — soft-delete with the same 7-day retention.
## Compare environments
The diff page compares two or more environments (default development vs production), classifying each key as matching, changed, or missing — based on vault references, not decrypted content, until you reveal. If two references differ but the decrypted values are byte-identical, a **content match** badge says so rather than reporting a false diff.
Search, filter to sensitive-only, sort, reveal individually or all at once, and export as per-environment `.env` files or a JSON/Markdown report. For a key missing from one environment there is a **copy-from** helper — there is no one-click sync mutation.
## Shared variables
The same credential often lives in several projects. Sharing keeps one row that every picked project reads, so a rotation is one edit.
Sharing is off until an owner turns it on under **Organization → Settings → Shared variables**. Turning it off later stops new sharing only; groups that already exist keep working for every client.
A row whose key also exists in other projects shows **same key in N projects**. Its **Share across projects** action opens a sheet listing every project you can manage: projects holding the same value are preselected and their copies are moved into the shared row (they land in each project's trash for 7 days); projects with a different value are listed but cannot be picked until that is resolved. Pick an existing group or name a new one and confirm.
Shared rows appear pinned above the table in every project that reads them, with a **N projects** pill. Edit and delete work in place and state how many projects change before you confirm. You can edit a shared row only if you could edit variables in every project it reaches. If any of those projects protects an environment the row is in, the edit is filed as a change request instead of landing directly.
**Stop sharing here** removes this project from the group. By default the current values are copied into the project first, so its next pull is unchanged. The last project to leave takes the group with it.
The CLI, the editor extensions, the MCP server, the GitHub Action and the Docker image all receive shared rows with no update: resolution happens on the server. Pushing a shared key from a client is refused with a message naming the group.
**Merge in one click.** The same settings tab lists every key that is identical across projects, and the dashboard shows the count. **Merge all** opens a sheet with three environment chips. Development and staging are on by default and merge on the spot. Production is off by default; when it is on, a key that reaches a project protecting production is not merged directly but filed as a change request for a second person to apply, exactly like a production edit. Keys with different values, or with an environment that is off, are held back with the reason and never touched.
Groups are listed and renamed under **Organization → Settings → Shared variables**. Shared variables are a Pro feature; on the free plan the switch shows "Not available on this plan".
## Trash
Soft-deleted variables and shared accounts are listed separately, each restorable individually. Retention is **7 days**, shown per row as "Deleted N days ago — M days left", turning red at one day or less. **Empty trash** purges everything immediately, destroying the vault values.
Restoring re-runs the uniqueness check — a restore that would collide with a newer variable is rejected, not merged.
## Shared accounts
`/accounts` holds [shared accounts](/platform/shared-accounts) — credential pairs for a shared Stripe login or a database admin account, kept separate from variables. Create with a name, optional URL, username, masked password, description, and environments. Edit, delete, reveal, manage permissions, and share, with the same 7-day trash retention.
## Sharing
The Sharing page manages [share links](/platform/sharing): one-time or time-limited links that reveal a single value to someone outside the project, gated by email verification. It shows totals — active, viewed, expired, revoked — and each card's type, recipient status, and countdown.
The only action here is **Revoke**. Links are created from the share button on a variable or account row.
## Members
A project's Members page assigns existing organization members to the project. For environment-scoped roles, a checklist restricts which environments they see. Roles themselves are organization-level — see [Roles & permissions](/platform/rbac).
## Settings
Two tabs:
- **General** — name, description, icon and colour, and VS Code auto-unsync behaviour (Pro).
- **Danger Zone** — transfer the project to another organization, or delete it. Deletion gives variables and accounts the same 7-day trash retention as a manual delete.
There is no CI/CD Tokens tab. Legacy service tokens still work through a compatibility fallback, but new automation uses an [API key](/api/authentication) from Organization Settings.
## Limits
- Environments are fixed at three. There is no way to add a fourth.
- Variable search returns at most 100 matches at a time.
- Diff compares vault references first — a reveal is what decrypts.
- Trash is 7 days, then permanent.
## Next
- [Organization administration](/dashboard/organization)
========================================================================
ORGANIZATION ADMINISTRATION
========================================================================
Source: https://docs.envpilot.dev/dashboard/organization
# Organization administration
## Members
Invite people, change roles, and remove them. Each row also carries **Suspend access** — [Security Hold](/platform/rbac#security-hold) — which freezes a member everywhere (web, CLI, extension, API) without touching their role or assignments, and optionally surfaces any API keys they created so you can revoke those too. A suspended row flips to **Reinstate**, restoring exactly what was frozen.
Invitations expire after 7 days and are only redeemable by the email address they were sent to.
## Usage
`/dashboard/usage` (owner only) is the plan page:
- current tier, price, upgrade and billing links
- an alert zone for anything at or near a limit
- **Quotas & limits** meters: organizations, projects, team members, variables per project, invitations, active shares, rotation-enabled variables, shared accounts, secret files — plus your audit and analytics retention windows
- **Plan features**, comparing your tier against Pro
If tier enforcement is off (pre-alpha mode), a badge says so. `envpilot usage --json` gives the same numbers in the terminal.
## Settings
Four tabs: **General**, **Tags**, **API Keys**, **Danger Zone**. General and Danger Zone are owner-only.
### Tags
Full CRUD over the organization's colour-coded tag set — create, bulk-paste, edit, delete. Deleting a tag strips it from every variable that used it.
### API keys
Create org-scoped [API keys](/api/authentication) for the REST API, MCP server, and GitHub Action. For each key you choose:
- **surfaces** — `rest_api`, `mcp_server`, `github_action`
- **projects** — specific ones, or all including future ones (owner-only)
- **environments**
- **resources** — `variables`, `accounts`, `projects`, `files`, `requests`
Each row shows who created it, when it was last used, pull / denial / request counts, and **Revoke**.
### Danger zone
Transfer the organization to another owner, or delete it.
## Billing
Billing and plan upgrades live on the personal account settings page (`/dashboard/settings`), not in organization settings.
## Limits
- Creating org-wide API keys is owner-only; team leads can create keys limited to specific projects.
- "All projects" scope includes projects created later, which is why it is owner-only.
- Security Hold requires the target to be strictly below your role, and Owners can never be suspended — transfer ownership first.
- Plan limits are enforced server-side; this page reflects them, it does not define them. See [Plans & limits](/limits/plans).
========================================================================
SLACK & DISCORD NOTIFICATIONS
========================================================================
Source: https://docs.envpilot.dev/integrations/notifications
# Slack & Discord Notifications
When a secret changes in production, the team should find out in the channel they already watch — not by checking the dashboard the next morning. Envpilot posts organization activity to Slack and Discord channels: variable changes, access requests, membership changes, and security events.
Notifications carry **key names, environments, and actor names — never secret values**. A notification channel is never a place where a secret can leak.
> Slack & Discord notifications are a **Pro** feature. See [Plans](/limits/plans).
## Connect a channel
Connecting takes one click — Envpilot uses each platform's OAuth flow, which creates the webhook for you:
1. Go to **Organization Settings → Integrations** (owner only).
2. Click **Connect Slack** or **Connect Discord**.
3. On the platform's consent screen, pick the channel to post to and approve.
4. You land back on the Integrations tab. Envpilot queues a test message and the row updates with the delivery result.
There is nothing to copy out of Slack or Discord admin screens — the platform hands Envpilot the webhook URL during consent. The URL is encrypted in WorkOS Vault; Convex keeps only its opaque Vault reference and a masked preview. The OAuth access token is discarded, never stored.
## Add a webhook manually
If the Connect buttons aren't available (a locked-down Slack workspace, or a self-hosted Envpilot without OAuth apps configured), paste a webhook URL instead:
- **Slack**: in the target channel → **Integrations → Add an app → Incoming Webhooks** → create and copy the URL (`https://hooks.slack.com/services/…`).
- **Discord**: channel settings → **Integrations → Webhooks → New Webhook** → **Copy Webhook URL** (`https://discord.com/api/webhooks/…`).
Then **Organization Settings → Integrations → Advanced setup**, pick the platform, paste the URL, and choose project routing. All webhook URLs — manual or OAuth-connected — are encrypted as credentials, and the UI only ever shows a masked preview.
## Route projects to channels
Every connected destination can receive activity from **all projects** or only a selected set of projects. Open **Manage** beside a destination to change its project routing and event groups.
For example, connect the Slack `#envpilot` channel, choose **Selected projects**, and select the EnvPilot project. That channel then receives only EnvPilot project activity. Connect Slack or Discord again to route other projects to different channels.
Organization-wide events that do not belong to a project, such as membership changes, are sent only to destinations configured for all projects. Existing destinations default to all projects.
## Event groups
Each destination subscribes to event groups, editable through **Manage** at any time:
| Group | Covers | Default |
| --------- | ------------------------------------------------------------ | ------- |
| Variables | created, updated, rotated, restored, rolled back, exported | on |
| Requests | access requested, approved, rejected | on |
| Members | invitations, removals, shared-account permission changes | off |
| Security | attributable API denials, API keys, device/extension revokes | off |
## Delivery behavior
- Delivery is fire-and-forget: the audited action only queues preparation and never waits for Slack or Discord.
- Requests time out after 10 seconds. Network errors, HTTP 5xx responses, and rate limits retry up to three total attempts; provider `Retry-After` guidance is respected.
- After **20 consecutive failures** a webhook auto-disables instead of hammering a dead endpoint. Re-enabling it (Resume) resets the counter.
- **Send test** queues a test message on demand; every newly added webhook queues one too. The settings row reports the final delivery status.
- Pausing, removing, or losing the feature gate stops already-queued deliveries because the endpoint is re-checked immediately before every post.
- Up to 10 webhooks per organization.
## Self-hosting: enabling the Connect buttons
The one-click Connect flow needs OAuth apps you register once per platform. Without these env vars the buttons hide and manual entry still works.
**Slack** — [api.slack.com/apps](https://api.slack.com/apps) → Create New App:
1. Under **OAuth & Permissions**, add the redirect URL `https:///api/integrations/slack/callback` and the bot scope `incoming-webhook`.
2. To install the app in workspaces other than its development workspace, complete Slack's **Manage Distribution** checklist and activate public distribution.
3. Copy the **Client ID** and **Client Secret** into your web environment:
```bash
SLACK_CLIENT_ID=...
SLACK_CLIENT_SECRET=...
```
**Discord** — [discord.com/developers/applications](https://discord.com/developers/applications) → New Application:
1. Under **OAuth2**, add the redirect `https:///api/integrations/discord/callback`.
2. Copy the **Client ID** and **Client Secret**:
```bash
DISCORD_CLIENT_ID=...
DISCORD_CLIENT_SECRET=...
```
For local Discord testing, register this exact redirect:
```text
http://localhost:3000/api/integrations/discord/callback
```
Slack requires OAuth redirect URLs to use HTTPS. Its PKCE exception for `http://localhost` is a desktop flow and cannot request the `incoming-webhook` bot scope. You can keep the app entirely local by running Next.js with local HTTPS:
```bash
bun --cwd apps/web run dev -- --experimental-https
```
Accept/trust the generated local certificate in the test browser, set both `NEXT_PUBLIC_APP_URL` and `WORKOS_REDIRECT_URI` to the HTTPS localhost origin, and add `https://localhost:3000/callback` to the WorkOS development environment's allowed redirect URIs. Then register:
```text
https://localhost:3000/api/integrations/slack/callback
https://localhost:3000/api/integrations/discord/callback
```
Alternatively, use a temporary HTTPS tunnel or a trusted local HTTPS reverse proxy and register its callback URL instead. If the app must remain on plain `http://localhost:3000`, use **Add manually** with a disposable Slack incoming webhook; delivery, retries, status, pause/resume, and removal still run end to end.
For Discord or manual-webhook testing, set `NEXT_PUBLIC_APP_URL=http://localhost:3000` in the web app's root `.env.local`. Set the same value in the Convex **development deployment** so notification links point back to localhost:
```bash
bunx convex env set NEXT_PUBLIC_APP_URL http://localhost:3000
```
The four OAuth client variables belong only in `.env.local`; do not put client secrets in Convex. `WORKOS_API_KEY` must already be configured in the Convex deployment because webhook URLs are encrypted in WorkOS Vault.
The consent screens use the `incoming-webhook` (Slack) and `webhook.incoming` (Discord) scopes — channel-post capability only, no read access to anything.
## Limits
- **Pro only** (`team_notifications`). Free organizations have the feature off and a channel limit of 0.
- **Up to 10 webhooks** per organization (`team_notifications_limit`).
- Delivery times out after **10 seconds** and retries up to **three total attempts**, respecting the provider's `Retry-After`.
- Slack and Discord only, via incoming webhooks — post-only scopes, no read access to your workspace.
- Manual **Send test** is rate limited to 5 per minute per organization.
- Notifications carry event metadata and links, never secret values.
========================================================================
HOW TO SHARE ENVIRONMENT VARIABLES SECURELY
========================================================================
Source: https://docs.envpilot.dev/guides/migrate-from-dotenv
# How to Share Environment Variables Securely with Your Team
Every development team hits this moment: a new engineer joins, clones the repo, runs the app — and it crashes because they don't have the `.env` file. So someone DMs it to them on Slack. It works, everyone moves on, and your production database credentials now live in a chat log forever.
This guide covers why that's a real problem, the common workarounds and their trade-offs, and how to set up secret sharing that's actually secure.
## Why sharing .env files over Slack is dangerous
It feels harmless because it's so common. But pasting secrets into chat creates problems that compound over time:
- **Secrets outlive their welcome.** Chat history is searchable and effectively permanent. A credential shared two years ago is still sitting there — long after the person who shared it left the company.
- **No revocation.** When someone leaves the team, you can remove their Slack account, but every secret they ever received is still on their laptop, in their email, in their notes.
- **No audit trail.** If a key leaks, you have no way to know who had access to it, when they got it, or which copy leaked.
- **Drift.** The `.env` file in chat is a snapshot. Three weeks later someone rotates a key, and half the team is debugging errors caused by stale values.
- **Compliance failure.** SOC 2, ISO 27001, and most security questionnaires explicitly ask how you distribute secrets. "We paste them in Slack" is a failing answer.
The same applies to email, shared Google Docs, Notion pages, and committing `.env` to a private repo. Private is not the same as secure — repo access is rarely scoped to who should see production credentials, and git history never forgets.
## What good secret sharing looks like
Whatever tool you use, secure team secret sharing has five properties:
1. **Encryption at rest** — secrets are stored encrypted (e.g. AES-256), not as plaintext in a database or document.
2. **Access control** — people see only the secrets they need. A frontend contractor doesn't need production database credentials.
3. **Revocation** — removing a person removes their access, immediately, without rotating every key they ever saw.
4. **Audit logging** — every read, write, and share is recorded with who/what/when.
5. **A single source of truth** — everyone pulls the current values from one place, so rotation propagates instead of causing drift.
## Common approaches, compared
**Encrypted files in the repo (SOPS, git-crypt, dotenv-vault).** Better than plaintext, and it keeps secrets versioned next to code. But key distribution just moves the problem (now you share the _decryption_ key), revocation still means re-encrypting everything, and there's no per-secret access control or audit trail.
**Cloud provider secret managers (AWS Secrets Manager, GCP Secret Manager).** Excellent for production workloads that already run in that cloud. But they're awkward for local development — every developer needs cloud IAM credentials, and the DX of pulling twenty variables into a local dev server is poor.
**Password managers (1Password, Bitwarden).** Fine for a handful of shared credentials, and far better than chat. But they're built for humans logging into websites, not for injecting forty variables into a process at runtime — so developers end up copy-pasting values back into local `.env` files, recreating the drift problem.
**A dedicated environment variable manager.** Purpose-built tools (Envpilot, Doppler, Infisical) store secrets encrypted, scope access by project/environment/role, keep an audit log, and inject variables directly into your process — so the `.env` file disappears entirely.
## The workflow with Envpilot
[Envpilot](/) stores secrets AES-256-encrypted in an isolated vault, with [role-based access control](/platform/rbac) down to the individual variable. The day-to-day workflow looks like this:
```bash
# One-time setup
npm install -g @envpilot/cli
envpilot login
envpilot init # link the repo to a project
# Run your app — variables are injected at runtime, no .env file written
envpilot run -- npm run dev
```
When a teammate joins, you add them to the project in the dashboard and they run the same three commands. When someone leaves, you remove them — every secret stays where it is, and their access is gone. When a key rotates, everyone gets the new value on their next run automatically.
For the full setup walkthrough, see [Getting Started](/start/quickstart).
## Checklist: migrating off shared .env files
1. Pick a single source of truth and import your current `.env` files into it.
2. Add `.env*` to `.gitignore` (and scrub any committed history with `git filter-repo` if needed).
3. **Rotate every secret that was ever shared over chat or email** — assume they're compromised, because you can't prove they aren't.
4. Scope access: production credentials to the people who deploy, staging to the team, local-dev to everyone.
5. Switch local development to runtime injection (`envpilot run -- `) so plaintext files stop being created at all.
6. Turn on audit logging and review it when offboarding.
The rotation step is the one teams skip — and it's the most important. A secrets manager protects you going forward; it can't un-leak what's already in your chat history.
## Limits
- Migrating does not invalidate the copies already circulating. Treat every previously shared secret as exposed and [rotate it](/platform/rotation).
- Free tier caps you at 3 projects, 50 variables per project, and 3 members.
- Bulk import is Pro-gated (`bulk_import`); on Free, paste variables through the dashboard's Bulk Paste tab or push them with the CLI.
========================================================================
NEXT.JS ENVIRONMENT VARIABLES BEST PRACTICES
========================================================================
Source: https://docs.envpilot.dev/guides/nextjs
# Next.js Environment Variables: Best Practices
Next.js has more environment variable behavior than almost any other framework — load order across multiple `.env` files, build-time inlining, the `NEXT_PUBLIC_` prefix, and different rules for server and client code. Most leaked frontend secrets trace back to misunderstanding one of these. This guide covers how it actually works and the practices that keep secrets safe.
## How Next.js loads .env files
Next.js loads environment files in a strict order, where earlier files win:
1. `process.env` (already-set variables always win)
2. `.env.$(NODE_ENV).local` — e.g. `.env.development.local`
3. `.env.local` (skipped when `NODE_ENV` is `test`)
4. `.env.$(NODE_ENV)` — e.g. `.env.production`
5. `.env`
Practical conventions that follow from this:
- **`.env`** — defaults safe to commit (feature flags, public URLs).
- **`.env.local`** — secrets and machine-specific overrides. Never commit it; Next.js's own `create-next-app` gitignores it for a reason.
- **`.env.production` / `.env.development`** — committed, environment-specific _non-secrets_.
If a variable mysteriously won't change, check the higher-priority files — a stale value in `.env.development.local` silently overrides everything below it.
## NEXT*PUBLIC* means "published to the world"
This is the single most important rule:
> Any variable prefixed with `NEXT_PUBLIC_` is **inlined into the JavaScript bundle at build time** and shipped to every visitor's browser.
It is not "available on the client" in some managed, scoped sense — it is plain text in your public JS files, visible to anyone who opens DevTools. Treat the prefix as a publish button:
```bash
# Fine — these are meant to be public
NEXT_PUBLIC_APP_URL=https://www.envpilot.dev
NEXT_PUBLIC_ANALYTICS_ID=abc123
# NEVER — this ships your key to every visitor
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_... # ✗
```
If a third-party SDK asks for a `NEXT_PUBLIC_` key, make sure it's the _publishable_ key, not the secret one. Server-only variables (no prefix) are stripped from client bundles automatically — accessing them in client components just yields `undefined`.
## Build time vs runtime
`NEXT_PUBLIC_` values are frozen **at build time**. Two consequences trip teams up:
- **Changing the variable later does nothing** until you rebuild. Updating it in your hosting dashboard and restarting is not enough.
- **One Docker image can't serve multiple environments** with different `NEXT_PUBLIC_` values — the values are baked in. If you need "build once, deploy anywhere", read public config at runtime instead: fetch it from an API route, or read server-side variables in a Server Component and pass them down as props.
Server-side variables (no prefix) are read at **runtime** from `process.env`, so they can differ per deployment without rebuilding.
## Keeping secrets server-side in the App Router
With the App Router, the server/client boundary is a file-level concern, and it's easy to drag a secret across it accidentally:
- Read secrets in **Server Components, Route Handlers, and Server Actions** only.
- Never pass a secret as a prop from a Server Component into a `"use client"` component — props are serialized into the HTML payload.
- For belt-and-suspenders enforcement, use the `server-only` package: importing a module that contains secrets from client code then fails the build instead of leaking.
```ts
// lib/payments.ts
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
```
## Validate variables at startup, not at first use
A missing variable should fail the build or boot — not throw at 2 a.m. when the code path finally runs. Validate once with a schema:
```ts
// env.ts
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
NEXT_PUBLIC_APP_URL: z.string().url(),
});
export const env = schema.parse(process.env);
```
Import `env` instead of touching `process.env` directly and typos become type errors.
## Stop distributing .env files to the team
Everything above keeps secrets out of the _bundle_ — but most leaks happen earlier, in how teams pass `.env.local` files around (Slack, email, Notion). The fix is a managed source of truth that injects variables at runtime instead of living in files:
```bash
envpilot run -- next dev # variables injected into the process, no .env.local on disk
```
With [Envpilot](/) the values are AES-256 encrypted, access is [role-scoped](/platform/rbac), every read is audited, and a rotated key reaches the whole team on their next run. See [How to Share Environment Variables Securely](/guides/migrate-from-dotenv) for the full migration checklist.
## Quick checklist
1. `.env*.local` in `.gitignore`; only non-secret defaults committed.
2. `NEXT_PUBLIC_` only on values you'd happily print on a billboard.
3. Secrets read in server code only; `server-only` package on secret-bearing modules.
4. Remember `NEXT_PUBLIC_` is baked at build time — rebuild to change it.
5. Validate `process.env` with a schema at boot.
6. Distribute team secrets through a secrets manager with runtime injection, not files in chat.
## Limits
- `NEXT_PUBLIC_` values are compiled into the browser bundle. Nothing Envpilot does makes them private again.
- Build-time values are frozen into the build; changing one in Envpilot needs a rebuild, not just a redeploy.
- `envpilot run` injects into the process, so anything reading `process.env` at request time picks changes up on restart.
========================================================================
ANDROID KEYSTORE IN CI
========================================================================
Source: https://docs.envpilot.dev/guides/android-keystore-ci
# Android keystore in CI
Every Android team has the same artefact: an upload keystore that must reach the release build and must never reach the repository. The usual workarounds — base64 in a CI secret, a file in a private bucket, a Slack message from three years ago — all lose the same thing: who has it, and who last used it.
This is the walkthrough for storing it as a [secret file](/platform/secret-files) instead.
## What you need
- A project in Envpilot, with the keystore's environment (`production`) available
- The CLI installed and linked: [`envpilot init`](/cli/link)
- An API key for CI with the **GitHub Action** surface and the **`files`** resource
## Store the keystore
## Wire up Gradle
Read the passwords from the environment rather than a checked-in `gradle.properties`:
```groovy
android {
signingConfigs {
release {
storeFile file("upload.jks")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
}
```
## Local builds
```bash
envpilot files pull -e production # writes android/app/upload.jks, mode 0600
envpilot run --env production -- ./gradlew bundleRelease
```
`files pull` gitignores the path before writing it, and refuses to overwrite a local copy that differs from the server unless you pass `--force`.
## The CI workflow
```yaml
name: Release
on:
push:
tags: ["v*"]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
project: mobile-app
files: true
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- run: ./gradlew bundleRelease
```
One step brings both halves: the passwords are exported to `$GITHUB_ENV` and masked in the log, and the keystore is written to `android/app/upload.jks` with mode `0600`.
`project` is required whenever `files: true` — the files endpoint is project-scoped.
## What you gained
| Before | After |
| ---------------------------------------- | ------------------------------------------------------- |
| Keystore in a base64 CI secret | Encrypted at rest, envelope-encrypted across two stores |
| No record of who downloaded it | Every fetch audited against a person or an API key |
| Rotating means editing every repo secret | Replace contents once; path and grants stay put |
| New hire needs someone to send it | `envpilot files pull` |
## Rotating the keystore
Use **Replace contents** in Project → Files: the bytes change, the path, environments and per-file grants stay. Every client picks up the new file on its next pull, and the old one is unrecoverable.
## Limits
- 8 MB per file on Pro, 256 KB on Free. A keystore is a few KB; a provisioning bundle might not be.
- Every content fetch is audited and rate-limited — a workflow that pulls on every push produces an entry per run.
- The Action overwrites the destination path without a conflict check. That is right for a runner, wrong for a laptop, which is why the CLI behaves differently.
- The `files` resource is never granted by default. A key that does not carry it is refused, not partially served.
## See also
- [Secret files](/platform/secret-files) · [CLI files](/cli/files) · [Action: secret files](/action/secret-files)
========================================================================
GIVING AN AGENT SECRETS SAFELY
========================================================================
Source: https://docs.envpilot.dev/guides/agents
# Giving an agent secrets safely
A coding agent that cannot see your environment variables writes code that guesses at them. An agent with your `.env` on disk is a credential you cannot revoke. The middle path is a scoped, audited, read-only key — plus a way for the agent to ask when it needs more.
## The shape of it
```mermaid
flowchart LR
A[Agent] -->|read, scoped| M[MCP server]
A -->|ask| M
M --> C[One enforcement core]
C --> V[(Vault)]
R[Human reviewer] -->|approve + supply value| C
```
The agent reads within its scope and files requests outside it. Every value it reads is logged against its key, and it can never write.
## Set it up
## What a good session looks like
1. The agent calls `envpilot_list_projects` to find the project.
2. It calls `envpilot_get_variables` with `metadata_only` to see which keys exist — no decrypt, no audit entry, no values in its context window.
3. It reads only the specific values it needs, by key.
4. Missing something? `envpilot_request_variable` with a justification. You approve in the dashboard or with `envpilot requests approve ` and type the value yourself.
Step 2 is the habit worth encouraging in your prompt: **look at key names first, fetch values last**. Most code an agent writes needs to know a variable exists, not what it says.
## Guardrails you get for free
| Risk | What stops it |
| ---------------------------------- | ------------------------------------------------------------------- |
| Agent edits a production secret | No machine credential can write. There is no such tool |
| Agent invents a value and files it | Requests carry no value; the reviewer supplies it |
| Agent loops and spams reviewers | 5 requests/hour, 5 open at a time, 24-hour cooldown after rejection |
| Key leaks into a log | Revoke it; the next call fails, with no grace period |
| You need to know what it read | Every value read is audited against the key |
## Guardrails you must add yourself
- **Scope narrowly.** "All projects" includes projects that do not exist yet.
- **Prefer development.** An agent rarely needs production to write code.
- **Set an expiry.** An experiment from March should not still hold a key in November.
- **Do not grant `files` casually.** A leaked string rotates in minutes; a leaked signing key does not.
- **Watch the context window.** Once a value is in a transcript, it lives wherever that transcript lives.
## Limits
- Pro plan (`mcp_server`), re-checked on every call.
- Scope is immutable — widening access means a new key and a client restart.
- Hosted Claude connectors expect OAuth and cannot carry a fixed bearer key; use Claude Code.
- Tool calls draw on the same rate buckets as REST for that key.
## See also
- [MCP overview](/mcp/overview) · [Tools](/mcp/tools) · [Agent requests](/mcp/agent-requests)
- [Architecture](/start/architecture) — the trust model in full