Production

Production & CI

Run envless on CI runners, in Docker, and on production hosts.

The auth model

On a developer machine, envless login caches an API token at ~/.envless/config.json, and envless passphrase set caches the workspace key into the same file, under workspaceKeys. CI runners and production hosts have neither, so they configure Envless entirely through environment variables, and that takes two separate credentials:

  • ENVLESS_TOKEN: an ev_sk_ API key that authenticates the request to fetch your encrypted bundle. The server only ever returns ciphertext, so the token alone can't read a value.
  • ENVLESS_KEY or ENVLESS_PASSPHRASE: the material that decrypts that ciphertext locally. It never leaves the runner and is never sent to the API.

Plus the selectors that say what to fetch:

  • ENVLESS_PRODUCT: the product slug, only needed when the project slug is ambiguous (falls back to .envless).
  • ENVLESS_PROJECT: the project slug (falls back to a committed .envless file).
  • ENVLESS_ENV: the environment slug (also falls back to .envless).

Machine & CI access is the full reference for these variables: where each one comes from, how to export ENVLESS_KEY from the dashboard, and how rotation works. This page is the platform-by-platform recipe book.

The full env-var set

VariableRequiredPurpose
ENVLESS_TOKEN✅ev_sk_ API key that authenticates the ciphertext fetch. From the Keys page.
ENVLESS_KEYone of thesebase64url raw AES-256 workspace key that decrypts locally. From Settings → CI / Machine Access.
ENVLESS_PASSPHRASEone of theseWorkspace passphrase. The key is derived from it instead.
ENVLESS_PRODUCT⬜ (or .envless)Product slug. Only required when the project slug exists in more than one product, which the API answers with 409 Conflict until you name one.
ENVLESS_PROJECT✅ (or .envless)Project slug to fetch.
ENVLESS_ENV✅ (or .envless)Environment slug to fetch.

Set one decryption credential. If both are set, ENVLESS_KEY wins and the passphrase is ignored. Prefer ENVLESS_KEY: it's the raw key, so the loader skips both the 200,000 round PBKDF2 derivation and the extra API call a passphrase needs to resolve the workspace before it can build the salt.

GitHub Actions

These env vars tell Envless what to fetch and how to decrypt it, but something in the job still has to do the fetching. Either the CLI wraps your build, or your build loads Envless itself.

.github/workflows/deploy.yml
jobs:
    deploy:
        runs-on: ubuntu-latest
        env:
            ENVLESS_TOKEN: ${{ secrets.ENVLESS_TOKEN }}
            ENVLESS_KEY: ${{ secrets.ENVLESS_KEY }}
            ENVLESS_PROJECT: my-app
            ENVLESS_ENV: production
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
            - run: npm ci
            # the envless binary ships in @goenvless/cli, which a runner does not have until you install it
            - run: npm i -g @goenvless/cli@latest
            - run: envless run -- npm run build

envless run exits with the child's own exit code, so a failing build still fails the job. A command it cannot start at all exits 127, and a child killed by a signal exits 128 plus the signal number.

An ev_sk_ token carries its own workspace scope, so the CLI does not ask for --workspace here. A user token from a cached envless login would.

Drop both CLI steps if the build already loads Envless on its own, through a withEnvless Next config or a node --import @goenvless/env/register entrypoint. Those paths need @goenvless/env in your package.json, nothing global, and npm ci installs it.

If .envless is committed and checked out, you can drop ENVLESS_PROJECT / ENVLESS_ENV and let the file supply them, but setting them explicitly is unambiguous and survives a checkout that omits the file.

Vercel

Wrap your Next config

Use the real config plugin so the encrypted bundle is loaded during next build:

next.config.mjs
import { withEnvless } from '@goenvless/env/next';

export default withEnvless({
    // your existing Next config
});

Add the credentials to the project

In your Vercel project's environment variables, set ENVLESS_TOKEN, ENVLESS_KEY, ENVLESS_PROJECT, and ENVLESS_ENV. withEnvless awaits load() before it hands the config back, writes the decrypted values into process.env for server and build code, and copies only the variables you marked client into config.env, next to an ENVLESS_CLIENT_ENV payload that @goenvless/env/client reads.

Server-visibility variables never reach config.env. Any one of them whose name starts with NEXT_PUBLIC_ is also deleted from process.env for the rest of the build, with a warning naming it, because Next would otherwise inline it into the client chunks on the strength of the prefix alone.

Keep the filesystem entries off the edge

@goenvless/env/server pulls in node:fs, node:os and node:path, because the loader reads .envless and the on-disk cache. The root . entry, /next, /vite and /register go through that same loader, so all five carry those builtins. An Edge Function and middleware.ts have none of them, so none of those five entries can run there.

Edge runtimes use @goenvless/env/edge instead. Its load() takes { token, project, environment }, one of key or passphrase, and the optional product, workspaceId, endpoint and fetch. It imports no Node builtins, keeps no cache, and returns a plain record rather than touching process.env. @goenvless/env/crypto is pure Web Crypto as well, so encrypt, decrypt, workspaceKey and passphraseStrength are available on the edge too. See Workers & Edge.

Scope previews

For preview deployments, set ENVLESS_ENV=staging (or whichever environment you mirror previews against) on Vercel's Preview scope.

Docker

Dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
# nothing in this stage loads Envless on its own, so the CLI has to wrap the build
RUN npm i -g @goenvless/cli@latest
COPY . .
ENV ENVLESS_PROJECT=my-app ENVLESS_ENV=production
RUN --mount=type=secret,id=envless_token \
    --mount=type=secret,id=envless_key \
    ENVLESS_TOKEN=$(cat /run/secrets/envless_token) \
    ENVLESS_KEY=$(cat /run/secrets/envless_key) \
    envless run -- npm run build

FROM node:20-slim
WORKDIR /app
COPY --from=build /app .
# ENVLESS_TOKEN + ENVLESS_KEY provided at runtime by the platform
ENV ENVLESS_PROJECT=my-app ENVLESS_ENV=production
CMD ["node", "--import", "@goenvless/env/register", "server.js"]
bash
docker build \
    --secret id=envless_token,env=ENVLESS_TOKEN \
    --secret id=envless_key,env=ENVLESS_KEY \
    -t myapp .

The token and key are mounted as build-time secrets, so they never enter the image layers.

Drop the global install and the envless run -- wrapper if your build already loads Envless itself, through withEnvless or the Vite plugin. Without one of the two, the build stage runs with the credentials in its environment and no variables, because setting ENVLESS_TOKEN does not by itself fetch anything.

The runtime stage needs the preload for the same reason. --import @goenvless/env/register runs await load() before server.js executes its first line, and /register is the only entry that fetches and decrypts on import.

Fly.io, Railway, Render

Set ENVLESS_TOKEN and ENVLESS_KEY as platform secrets, and ENVLESS_PROJECT / ENVLESS_ENV as plain env vars. Build and run as normal. node --import @goenvless/env/register fetches and decrypts at boot, before your entry file runs.

Nothing else loads on its own. A process that neither preloads /register nor awaits load() starts with the credentials in its environment and an empty store, and the first read off env throws Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.

Kubernetes

yaml
env:
    - name: ENVLESS_TOKEN
      valueFrom:
          secretKeyRef:
              name: envless
              key: token
    - name: ENVLESS_KEY
      valueFrom:
          secretKeyRef:
              name: envless
              key: key
    - name: ENVLESS_PROJECT
      value: my-app
    - name: ENVLESS_ENV
      value: production

These arrive as plain env vars, so the image still has to load. The --import @goenvless/env/register preload in the container's CMD is what turns them into variables at pod startup. For zero-downtime rotation, pair with an ExternalSecret operator to roll the token/key secrets.

Choosing the environment

Envless resolves the project and environment in this order:

  1. The --project / --env flag (CLI commands only).
  2. The ENVLESS_PROJECT / ENVLESS_ENV env var.
  3. The committed .envless file.

The product resolves the same way, through --product, then ENVLESS_PRODUCT, then the product key in .envless. The environment falls back to the first entry in the file's environments array, and envless run warns when it picks one out of several.

In CI, set ENVLESS_PROJECT and ENVLESS_ENV explicitly. Relying on the file is brittle across checkouts.

What ships to the runtime

ComponentDevCI buildProduction runtime
.envlesscommittedcommittedcommitted
envless-env.d.tscommittedcommittedcommitted
ENVLESS_TOKENCLI only, from the cached loginrequiredrequired
ENVLESS_KEY / ENVLESS_PASSPHRASECLI only, from the cached keyrequiredrequired
Encrypted bundlefetched on demandfetched at buildfetched at boot
Plaintext valuesin the child process env, or process.env under the loaderin process.env, client ones also inlined into client chunksin process.env

Those two dev cells belong to the CLI. The @goenvless/env loader has neither fallback: it rejects any token that is not an ev_sk_ machine key, with ENVLESS_TOKEN must be a machine key (ev_sk_…), and it never consults the cached workspaceKeys. A node --import @goenvless/env/register process therefore needs ENVLESS_TOKEN plus ENVLESS_KEY or ENVLESS_PASSPHRASE on a laptop exactly as it does on a runner.

The loader does not clobber the runner. A name the platform already set in process.env keeps its value, and Envless only fills in the ones that are absent or that it wrote on a previous load. Rename the variable in Envless, or unset it on the platform, when the two disagree. envless run is the other way round: it hands the child the runner's own environment with the decrypted values layered over it, so there an Envless variable wins.

Only the @goenvless/env loader fills the on-disk cache. It writes the ciphertext to ~/.envless/cache and reuses it for 15 minutes before refetching, which is what makes a restart cheap. The CLI never reads or writes that cache, and envless run fetches every time. It only ever deletes from it: envless cache clear (--all wipes the whole directory), plus an automatic invalidation after envless push, envless var set, envless var remove, envless version restore, envless env delete and envless project delete. Caching & Reloads covers the window in detail.

A variable whose name would change how a process executes code is decrypted but deliberately not written into process.env, and not passed to the child of envless run either. The list is fifteen names, matched on the whole name and not by prefix, and without regard to case, so path and node_options are refused exactly like PATH and NODE_OPTIONS: NODE_OPTIONS, NODE_PATH, NODE_REPL_EXTERNAL_MODULE, ELECTRON_RUN_AS_NODE, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, PATH, BUN_INSPECT, BUN_INSPECT_CONNECT_TO, RUBYOPT, RUBYLIB, BUNDLE_GEMFILE, GEM_HOME, GEM_PATH, PYTHONPATH, PYTHONHOME, PYTHONSTARTUP, PYTHONUSERBASE, PHPRC, PHP_INI_SCAN_DIR, ENVLESS_CLIENT_ENV and ENVLESS_INJECTED. Both the loader and the CLI print a warning naming the ones they refused. Under the loader the values are still in the store, so env from @goenvless/env/server reads them normally, they simply never become process environment.

Going deeper

The same machine-credential pattern applies to anything unattended, including a coding agent running on a developer machine.