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.
Choosing an approach
Section titled “Choosing an approach”Before adding varlock to a Dockerfile, decide which role it plays:
| Approach | When to use | varlock in production image? |
|---|---|---|
| CI-only validation | Validate schema and config in pipelines; runtime env comes from the platform | No. Run varlock load in CI only |
| Runtime injection | Long-running containers (APIs, workers) that resolve secrets at boot via plugins | Yes. Use varlock run as the entrypoint |
| Build-time | SSR apps where a framework integration injects resolved env into build output | Sometimes. 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.
- 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 loadSee also the GitHub Action for a native Actions integration.
Use when your container must resolve secrets at boot, for example via a 1Password service account token, AWS IAM, or OIDC workload identity. The final image runs your app through varlock run:
ENTRYPOINT ["varlock", "run", "--"]CMD ["node", "dist/server.js"]Secrets are fetched when the container starts, not when the image is built.
varlock run stays resident while your app runs (signal forwarding), so the container pays for two processes. That is usually fine once the CLI is a few tens of MiB. If you are on a very tight memory limit and only need env at boot, prefer resolve-then-exec so a single Node process remains: eval "$(varlock load --format shell --compact)" && exec node dist/server.js.
Use when a framework integration (Next.js, Vite SSR, Cloudflare Workers, etc.) resolves and injects env during build. Varlock belongs in the builder stage; the runtime image receives env via the platform or an encrypted blob, not via the CLI.
For serverless SSR, prefer encrypted deployments over baking resolved values into image layers.
When not to put varlock in your production image
Section titled “When not to put varlock in your production image”Official Docker image
Section titled “Official Docker 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)
# Pull a pinned version (recommended)docker pull ghcr.io/dmno-dev/varlock:1.6.0
# Run helpdocker run --rm ghcr.io/dmno-dev/varlock:1.6.0 --help
# Validate schema in the current directorydocker run --rm \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 loadAvailable tags
Section titled “Available tags”ghcr.io/dmno-dev/varlock:latest: latest stable releaseghcr.io/dmno-dev/varlock:1.6.0: specific version (replace with the version you pin in CI/production)
Best practices
Section titled “Best practices”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:
# BAD: resolved values become part of the imageRUN varlock load > /app/.env.productionRUN varlock run -- node scripts/build.js # if build embeds secrets in outputARG OP_SERVICE_ACCOUNT_TOKEN # build args are also stored in metadataInstead:
- 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
Use the /work mount and PWD=/work pattern
Section titled “Use the /work mount and PWD=/work pattern”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:
docker run --rm \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 loadIf your app lives in a subdirectory, mount that directory to /work (or adjust -w and PWD together).
Pin versions
Section titled “Pin versions”Pin the GHCR image tag and any plugin versions in .env.schema when using the standalone binary (see plugins in containers).
Multi-stage builds
Section titled “Multi-stage builds”Copy the varlock binary from the official image into your application image. No need to install Node or curl in the final stage:
FROM ghcr.io/dmno-dev/varlock:1.6.0 AS varlock
FROM node:22-alpine AS builderCOPY --from=varlock /usr/local/bin/varlock /usr/local/bin/varlock
WORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfile
COPY . .# Validate schema during build (no secrets written to disk)RUN varlock loadRUN pnpm build
FROM node:22-alpine AS runnerWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/.env.schema ./.env.schema
# Runtime: resolve secrets when the container startsCOPY --from=varlock /usr/local/bin/varlock /usr/local/bin/varlockENTRYPOINT ["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/varlockRuntime: entrypoint and Docker Compose
Section titled “Runtime: entrypoint and Docker Compose”For containers that resolve secrets at boot, set varlock as the entrypoint and pass your process after --:
ENTRYPOINT ["varlock", "run", "--"]CMD ["node", "dist/server.js"]Inject the environment flag and secret-zero credentials via your orchestrator, not via docker build:
docker run --rm \ -e APP_ENV=production \ -e OP_SERVICE_ACCOUNT_TOKEN \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ my-app:latestDocker Compose
Section titled “Docker Compose”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: /workMonorepos and partial build context
Section titled “Monorepos and partial build context”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:
# @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
Section titled “varlock flatten”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:
cd apps/web && varlock flattenThe 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:
# builder stage has the full monorepoFROM node:22-slim AS builderWORKDIR /repoCOPY . .RUN npm installRUN cd apps/web && npx varlock flattenRUN cd apps/web && npm run build
FROM node:22-slimCOPY --from=ghcr.io/dmno-dev/varlock:1.6.0 /usr/local/bin/varlock /usr/local/bin/varlockWORKDIR /appCOPY --from=builder /repo/apps/web /app# overlay the flattened env files over the originalsCOPY --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].localfiles are skipped by default, so machine-local secrets don’t end up in image layers. Use--include-localif 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’snode_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:
# 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 loadAlternatively, 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.
Plugins in containers
Section titled “Plugins in containers”Containers commonly authenticate to secret providers in two ways:
- Service account tokens: pass a long-lived token (e.g.
OP_SERVICE_ACCOUNT_TOKEN) as a runtime environment variable - 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:
# @plugin(@varlock/1password-plugin@1.2.3)# @initOp(token=$OP_SERVICE_ACCOUNT_TOKEN)# ---OP_SERVICE_ACCOUNT_TOKEN= # @sensitiveDATABASE_URL=op(op://prod/db/connection-string) # @sensitiveIn 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.
Distroless and shell-less base images
Section titled “Distroless and shell-less base images”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.
Vendor plugins with flatten --vendor-plugins (recommended)
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:
# builder stage (has the full monorepo + network)RUN cd packages/api && varlock flatten --vendor-plugins
# final distroless stageCOPY --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.
Or pre-cache with install-plugin
Section titled “Or pre-cache with install-plugin”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:
ENV XDG_CONFIG_HOME=/varlock-config
# builder stage: pre-cache the plugin into $XDG_CONFIG_HOME/varlock/plugins-cacheRUN 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-configWithout the official image
Section titled “Without the official image”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:
FROM node:22-alpine AS builderWORKDIR /app
COPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfile
COPY . .RUN pnpm exec varlock loadRUN pnpm buildInstall varlock and any plugins in package.json. The npm package includes the CLI.
Download a release binary during build (similar to local installation):
FROM alpine:3.19RUN apk add --no-cache curl ca-certificates libstdc++ \ && curl -sSfL https://varlock.dev/install.sh | sh -s -- --version=1.6.0 --dir=/usr/local/bin
WORKDIR /workENTRYPOINT ["/usr/local/bin/varlock"]Pass --version=x.y.z for reproducible builds. For multi-arch images, prefer the release tarball tab, since install.sh picks the host architecture at install time.
Matches how the official Dockerfile is built. Download a versioned binary from GitHub releases:
FROM alpine:3.19 AS varlock-builderARG VARLOCK_VERSION=1.6.0ARG TARGETARCH
RUN apk add --no-cache curl tar \ && ARCH="$([ "$TARGETARCH" = "arm64" ] && echo linux-musl-arm64 || echo linux-musl-x64)" \ && curl -L -o varlock.tar.gz \ "https://github.com/dmno-dev/varlock/releases/download/varlock@${VARLOCK_VERSION}/varlock-${ARCH}.tar.gz" \ && tar -xzf varlock.tar.gz \ && chmod +x varlock
FROM alpine:3.19RUN apk add --no-cache ca-certificates libstdc++COPY --from=varlock-builder /varlock /usr/local/bin/varlockWORKDIR /workENTRYPOINT ["/usr/local/bin/varlock"]Build with Buildx for multi-platform support (TARGETARCH is set automatically).
Building the image locally
Section titled “Building the image locally”The repository root Dockerfile builds the GHCR image. To build locally:
# Pin a specific varlock versiondocker build --build-arg VARLOCK_VERSION=1.6.0 -t varlock:local .
# Build using the latest GitHub releasedocker build --build-arg VARLOCK_VERSION=latest -t varlock:local .When things go wrong
Section titled “When things go wrong”Permission denied on mounted volumes
Section titled “Permission denied on mounted volumes”Bind mounts inherit host ownership. If varlock cannot read .env.schema or write cache files:
docker run --rm \ -u "$(id -u):$(id -g)" \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 loadOr adjust host file permissions so the container user can read your env files.
Network / plugin connectivity
Section titled “Network / plugin connectivity”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 hostonly in local dev, not in production - Check that secret-zero tokens are passed at runtime, not only at build time
# Local dev only: host network for desktop CLI integrationsdocker run --rm --network host \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 loadFor cloud deployments, prefer service account tokens or OIDC over desktop CLI integrations.
Missing @import files
Section titled “Missing @import files”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.
Wrong environment loaded
Section titled “Wrong environment loaded”If .env.production is not loading, verify the environment flag is set in the container environment:
docker run --rm -e APP_ENV=production -e PWD=/work -w /work ...See the Environments guide for how @currentEnv selects files.