Skip to content

Docker

Varlock works well in containerized workflows, but how you use it depends on what you need at each stage of your pipeline. This guide covers the official GHCR image, patterns for installing varlock yourself, and the pitfalls to avoid when secrets meet Docker layers.

Before adding varlock to a Dockerfile, decide which role it plays:

ApproachWhen to usevarlock in production image?
CI-only validationValidate schema and config in pipelines; runtime env comes from the platformNo. Run varlock load in CI only
Runtime injectionLong-running containers (APIs, workers) that resolve secrets at boot via pluginsYes. Use varlock run as the entrypoint
Build-timeSSR apps where a framework integration injects resolved env into build outputSometimes. Only in a builder stage, not the final runtime image

Use varlock in your pipeline to catch schema errors before deploy. Your production container does not need the CLI. The hosting platform (or orchestrator) injects env vars, and varlock has already validated the config graph in CI.

.github/workflows/ci.yml
- name: Validate environment schema
run: |
docker run --rm \
-v ${{ github.workspace }}:/work \
-w /work \
-e PWD=/work \
ghcr.io/dmno-dev/varlock:1.0.2 load

See also the GitHub Action for a native Actions integration.

When not to put varlock in your production image

Section titled “When not to put varlock in your production image”

The image is published to GitHub Container Registry and built from the official release binaries on Alpine Linux:

  • Image: ghcr.io/dmno-dev/varlock
  • Binary path: /usr/local/bin/varlock
  • Default workdir: /work
  • Entrypoint: varlock (pass subcommands as arguments)
Terminal window
# Pull a pinned version (recommended)
docker pull ghcr.io/dmno-dev/varlock:1.6.0
# Run help
docker run --rm ghcr.io/dmno-dev/varlock:1.6.0 --help
# Validate schema in the current directory
docker run --rm \
-v "$(pwd):/work" \
-w /work \
-e PWD=/work \
ghcr.io/dmno-dev/varlock:1.6.0 load
  • ghcr.io/dmno-dev/varlock:latest: latest stable release
  • ghcr.io/dmno-dev/varlock:1.6.0: specific version (replace with the version you pin in CI/production)

Never bake resolved secrets into image layers

Section titled “Never bake resolved secrets into image layers”

Anything written during RUN is persisted in the image history. Avoid:

Anti-pattern: secrets in layers
# BAD: resolved values become part of the image
RUN varlock load > /app/.env.production
RUN varlock run -- node scripts/build.js # if build embeds secrets in output
ARG OP_SERVICE_ACCOUNT_TOKEN # build args are also stored in metadata

Instead:

  • Pass secret-zero credentials at runtime via environment variables or orchestrator secrets
  • Use multi-stage builds so builder-stage artifacts containing secrets are discarded
  • For SSR, use @encryptInjectedEnv so build output holds ciphertext, not plaintext

The official image sets WORKDIR /work. When bind-mounting your project, mirror that layout and set PWD explicitly. Varlock uses the process working directory to resolve relative @import() paths and .env files:

Terminal window
docker run --rm \
-v "$(pwd):/work" \
-w /work \
-e PWD=/work \
ghcr.io/dmno-dev/varlock:1.6.0 load

If your app lives in a subdirectory, mount that directory to /work (or adjust -w and PWD together).

Pin the GHCR image tag and any plugin versions in .env.schema when using the standalone binary (see plugins in containers).

Copy the varlock binary from the official image into your application image. No need to install Node or curl in the final stage:

Dockerfile
FROM ghcr.io/dmno-dev/varlock:1.6.0 AS varlock
FROM node:22-alpine AS builder
COPY --from=varlock /usr/local/bin/varlock /usr/local/bin/varlock
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
# Validate schema during build (no secrets written to disk)
RUN varlock load
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.env.schema ./.env.schema
# Runtime: resolve secrets when the container starts
COPY --from=varlock /usr/local/bin/varlock /usr/local/bin/varlock
ENTRYPOINT ["varlock", "run", "--"]
CMD ["node", "dist/server.js"]

You can also use COPY --from=ghcr.io/dmno-dev/varlock:1.6.0 directly without a named stage:

COPY --from=ghcr.io/dmno-dev/varlock:1.6.0 /usr/local/bin/varlock /usr/local/bin/varlock

For containers that resolve secrets at boot, set varlock as the entrypoint and pass your process after --:

Dockerfile
ENTRYPOINT ["varlock", "run", "--"]
CMD ["node", "dist/server.js"]

Inject the environment flag and secret-zero credentials via your orchestrator, not via docker build:

Terminal window
docker run --rm \
-e APP_ENV=production \
-e OP_SERVICE_ACCOUNT_TOKEN \
-v "$(pwd):/work" \
-w /work \
-e PWD=/work \
my-app:latest
docker-compose.yml
services:
app:
build: .
environment:
APP_ENV: production
OP_SERVICE_ACCOUNT_TOKEN: ${OP_SERVICE_ACCOUNT_TOKEN}
entrypoint: ["varlock", "run", "--"]
command: ["node", "dist/server.js"]
volumes:
- .:/work
working_dir: /work

In a monorepo, .env.schema often uses @import() to pull shared config from other packages. For Docker builds, prefer importing specific files (optionally with pick) over whole directories. A directory import also pulls sibling .env.local / .env.[currentEnv] files, which you generally don’t want baked into an image and may not have copied into the build context:

apps/web/.env.schema
# @import(../../shared/.env.common)
# @import(../api/.env.schema, pick=[DATABASE_URL, REDIS_URL])

When Docker’s build context is limited to apps/web/, those imported files are not in the context, so varlock load fails with missing file errors.

varlock flatten collapses the whole import graph into one self-contained directory (default .env-flat/) and rewrites the @import paths, so the package’s env files no longer reach outside the package:

Terminal window
cd apps/web && varlock flatten

The output directory contains rewritten copies of the package’s own env files at its root, plus everything they import mirrored under .env-imports/. It is position-independent: copy its contents over your app directory (replacing the originals, whose imports still point outside) and varlock load / varlock run work with nothing else present.

The most common flow runs flatten in the builder stage of a multi-stage build, while the full monorepo is still around, then overlays the output in the final stage:

Dockerfile: multi-stage with flatten
# builder stage has the full monorepo
FROM node:22-slim AS builder
WORKDIR /repo
COPY . .
RUN npm install
RUN cd apps/web && npx varlock flatten
RUN cd apps/web && npm run build
FROM node:22-slim
COPY --from=ghcr.io/dmno-dev/varlock:1.6.0 /usr/local/bin/varlock /usr/local/bin/varlock
WORKDIR /app
COPY --from=builder /repo/apps/web /app
# overlay the flattened env files over the originals
COPY --from=builder /repo/apps/web/.env-flat/ /app/
ENTRYPOINT ["varlock", "run", "--"]
CMD ["node", "dist/server.js"]

You can also run flatten on the host or in CI before docker build, when the build context is limited to the package directory. It never resolves values or executes plugins, so it needs no secrets wherever it runs. Add the output directory to .gitignore (and make sure .dockerignore does not exclude it).

A few behaviors worth knowing (see the command reference for the full list):

  • .env.local / .env.[env].local files are skipped by default, so machine-local secrets don’t end up in image layers. Use --include-local if you really want them.
  • Conditionally-enabled imports (enabled=) are copied too, with their conditions preserved, since production may enable imports your build machine doesn’t.
  • @plugin() declarations in copied files get pinned to the installed version, so the standalone binary can auto-install them in the container without the sibling package’s node_modules.

Alternative: copy imported files explicitly

Section titled “Alternative: copy imported files explicitly”

If you prefer not to add a build step, copy every file the import graph needs:

Dockerfile: monorepo env files
# Build from repo root: docker build -f apps/web/Dockerfile .
ARG SERVICE_DIR=web
COPY .env.schema ./
COPY shared/.env.common ./shared/
COPY apps/${SERVICE_DIR}/.env.schema ./apps/${SERVICE_DIR}/
COPY apps/${SERVICE_DIR}/.env.production ./apps/${SERVICE_DIR}/
WORKDIR /app/apps/${SERVICE_DIR}
RUN varlock load

Alternatively, set the build context to the monorepo root and use docker build -f apps/web/Dockerfile . so all import paths resolve naturally, at the cost of a larger context and slower builds.

Containers commonly authenticate to secret providers in two ways:

  1. Service account tokens: pass a long-lived token (e.g. OP_SERVICE_ACCOUNT_TOKEN) as a runtime environment variable
  2. OIDC workload identity: exchange a short-lived platform token for temporary credentials; no secret-zero in the image

For OIDC on Fly.io, GCP Cloud Run, GitHub Actions, and other platforms, see the OIDC workload identity guide.

When using the standalone binary (copied from GHCR or a release tarball), plugins must specify a fixed version in @plugin(). Semver ranges require a local node_modules install:

.env.schema
# @plugin(@varlock/1password-plugin@1.2.3)
# @initOp(token=$OP_SERVICE_ACCOUNT_TOKEN)
# ---
OP_SERVICE_ACCOUNT_TOKEN= # @sensitive
DATABASE_URL=op(op://prod/db/connection-string) # @sensitive

In Node-based images where varlock is an npm dependency, install plugins in package.json and reference them without a version (or with a range for validation only). See the Plugins guide for details.

Auto-install works in minimal base images (Google distroless, scratch-based, etc.) that have no shell and no tar binary. The standalone binary downloads and extracts pinned plugin versions itself, so official @varlock/* plugins resolve at runtime with no extra setup.

Runtime auto-install still has two limits: third-party (non-@varlock) plugins need a one-time trust confirmation (an interactive terminal), and any auto-install needs network access to npm at container start. For a fully self-contained image that has neither, vendor the plugins at build time.

Section titled “Vendor plugins with flatten --vendor-plugins (recommended)”

If you already use varlock flatten for a monorepo build, add --vendor-plugins. It copies each @plugin() package into the output (.env-plugins/) and rewrites the declarations to local paths. Plugins already in your node_modules are copied directly (no network); anything not installed is downloaded. The flattened directory then resolves with no runtime npm fetch, no shell, and no trust prompt:

Dockerfile (excerpt)
# builder stage (has the full monorepo + network)
RUN cd packages/api && varlock flatten --vendor-plugins
# final distroless stage
COPY --from=builder /repo/packages/api/.env-flat/ /app/

This is the most complete option: it covers third-party plugins and offline runtimes, which runtime auto-install cannot.

Without flatten, pre-cache plugins in a stage that has a shell and copy the cache across. Pin the cache location with XDG_CONFIG_HOME so both stages agree:

Dockerfile (excerpt)
ENV XDG_CONFIG_HOME=/varlock-config
# builder stage: pre-cache the plugin into $XDG_CONFIG_HOME/varlock/plugins-cache
RUN varlock install-plugin my-plugin@1.2.3
# final stage: reuse the populated cache (set the same XDG_CONFIG_HOME here too)
COPY --from=builder /varlock-config /varlock-config

If you cannot pull from GHCR (air-gapped registry policy, custom base images, etc.), install varlock by another method:

Best when your image already uses Node 22+ and varlock is a project dependency:

Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm exec varlock load
RUN pnpm build

Install varlock and any plugins in package.json. The npm package includes the CLI.

The repository root Dockerfile builds the GHCR image. To build locally:

Terminal window
# Pin a specific varlock version
docker build --build-arg VARLOCK_VERSION=1.6.0 -t varlock:local .
# Build using the latest GitHub release
docker build --build-arg VARLOCK_VERSION=latest -t varlock:local .

Bind mounts inherit host ownership. If varlock cannot read .env.schema or write cache files:

Terminal window
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$(pwd):/work" \
-w /work \
-e PWD=/work \
ghcr.io/dmno-dev/varlock:1.6.0 load

Or adjust host file permissions so the container user can read your env files.

Plugins that call external APIs (1Password, AWS, Azure, Vault, etc.) need outbound network access from the container. If fetches fail with connection errors:

  • Confirm the container has DNS and egress to the provider’s endpoints
  • For local CLI tools (e.g. 1Password desktop app), use --network host only in local dev, not in production
  • Check that secret-zero tokens are passed at runtime, not only at build time
Terminal window
# Local dev only: host network for desktop CLI integrations
docker run --rm --network host \
-v "$(pwd):/work" \
-w /work \
-e PWD=/work \
ghcr.io/dmno-dev/varlock:1.6.0 load

For cloud deployments, prefer service account tokens or OIDC over desktop CLI integrations.

See Monorepos and partial build context. The error usually lists a path outside your Docker build context. Run varlock flatten while the full monorepo is available, add explicit COPY instructions, or widen the context to the monorepo root.

If .env.production is not loading, verify the environment flag is set in the container environment:

Terminal window
docker run --rm -e APP_ENV=production -e PWD=/work -w /work ...

See the Environments guide for how @currentEnv selects files.