Rate limits
Envpilot is an open-source platform, and its rate limits are documented so you can build integrations that respect them instead of discovering them by getting throttled. Every machine surface — the REST API, the MCP server, the GitHub Action, the Docker image, and machine-filed variable requests — is metered per credential with a token-bucket limiter.
The buckets#
Each limit is scoped per key. Two keys never share a bucket.
| Bucket | Limit | Applies to |
|---|---|---|
| Value pulls | 30 / min | CI/CD secret pulls (GitHub Action), and any REST/MCP call that decrypts values |
| Docker pulls | refill 30 / min, burst 120 | Container secret pulls — every call the Docker image makes that returns values |
| Metadata reads | 120 / min | REST + MCP metadata calls — org, project lists, metadata_only=true reads |
| Secret file reads | refill 60 / min, burst 1000 | Any call returning secret-file contents — CLI pull, Action, REST, MCP |
| Secret file uploads | 20 / min | Uploading a secret file (per user, dashboard or CLI) |
| Variable writes | 60 / min, burst 120 | Creating one variable at a time in the dashboard |
| Bulk variable writes | refill 300 / min, burst 500 | Imports, envpilot push, and project templates — charged once per batch |
| Variable requests | 5 / hour, burst 2 | Machine-filed variable requests (envpilot_request_variable) |
| Documentation pages | 30 / hour, burst 10 | Machine-authored documentation drafts (envpilot_create_doc) |
The value-pull bucket has full capacity available as a burst (deploys fan out matrix builds, so a spike is normal) while the sustained rate stays at 30/min. The metadata bucket is higher because those reads never touch the vault — no decrypt cost.
The Docker bucket is separate from value pulls on purpose: a fleet of containers restarting must not spend the budget your CI pipeline depends on, and a crash-looping container must not throttle your deploys. Its numbers come from the workload rather than a round number — a container start costs at most two value-returning calls (the variables pull, plus one file-content batch when the entrypoint uses --files), and the burst is sized for 60 of them starting at once, so one rolling restart of a large replica set fits in a single burst. It then refills that full burst over four minutes, which is far above any healthy restart rate and far below a loop. The image honours Retry-After automatically, so a project whose secret files span several batches slows down rather than failing.
The bulk variable write bucket is what an import, a envpilot push, or a project template spends, and it is charged once for the whole batch rather than once per variable. That distinction matters: charging per variable meant a 48-variable .env import stopped at the 31st key and left the project half-populated. A batch is now one reservation and one transaction, so it either lands completely or not at all. The burst equals the 500-variable ceiling a single batch may contain, so any batch the write path accepts can always be paid for in one go.
The secret file read bucket is the odd one, and deliberately: burst and refill are different numbers. Clients fetch one file per call, so a cold pull of a large project is a burst of hundreds — a bucket that refilled as fast as it drained would license a sustained 10 files/second forever, which is exactly an exfiltration profile. Splitting them gives a first pull its one-off burst and then settles to one file per second. The burst equals the hard ceiling of 1000 files per project, so any project that can exist can always be pulled in one go. A second pull moments later is served from files already in sync and decrypts nothing at all.
Secret-file uploads are metered per user rather than per key, because every upload encrypts, writes a blob, and creates a vault object.
Variable requests are capped twice#
Machine-filed requests get an extra layer beyond the per-hour rate limit, because every created request emails a human reviewer — a retry-looping agent must be stopped before it becomes reviewer alert fatigue:
- Rate limit — 5 per hour per key, with a burst of 2. This throttles how fast requests can be filed.
- Standing open-pendings cap — a key may have at most 5 open pending requests at once. Even within the rate limit, the 6th outstanding request is refused until a human reviews one of the existing five.
- Rejection cooldown — after a request for a given key is rejected, the same key cannot re-file a request for that variable for 24 hours. A rejected ask is a decision, not an invitation to immediately retry.
The GitHub Action never files requests at all, so none of these apply to it — it only draws from the value-pull bucket. The Docker image never files requests either, and draws only from the Docker bucket.
How many keys you may hold#
Rate limits bound how often a credential is used. A separate, plan-level limit bounds how many live credentials exist per surface, because each one is a standing key that returns plaintext every time it runs.
| Surface | Free | Pro |
|---|---|---|
| Docker | 0 | 10 |
| GitHub Action | 0 | 10 |
Both surfaces are Pro features, so the free tier holds none. Revoked and expired keys free their slot immediately, which is what makes rotating a credential possible while at the limit. All surfaces together are additionally bounded by 25 keys per organization.
These are plan defaults and can be adjusted per organization.
What happens when you exceed a limit#
- You are temporarily blocked, not queued. The request is rejected immediately with a
429. Envpilot does not hold requests and replay them later — an over-limit call fails and it is your integration's job to back off. - The error tells you how long to wait. A
429carries aRetry-Afterheader (and the error message names the retry-after window) with the number of seconds until the bucket refills enough to try again. - Bursts are blocked instantly. Once a bucket is empty, further calls fail on arrival — there is no grace window and no partial service. This is what keeps an abusive loop from amplifying load.
- Unknown keys are throttled separately. Requests presenting a key hash that matches nothing on file are rate-limited per hash, to slow brute-force key guessing without affecting real keys.
Keeping this page honest#
This table mirrors the limiter configuration in convex/lib/rateLimits.ts — where the Docker bucket's capacity and refill are computed from named constants rather than written as literals — and the request caps in the variable-request mutations. That source file carries a matching comment pointing back here, so the two are kept in sync when limits change. If you are integrating against Envpilot and something here looks off, the code is the source of truth — and it's public.
See Architecture for how the surfaces and the request loop fit together, and API Security for the auth and audit model behind every request.