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.

Workaround: copy imported files explicitly

Section titled “Workaround: copy imported files explicitly”

Until a dedicated varlock flatten command ships (to inline the import graph into a local directory for Docker builds), 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.

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