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.
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.
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:
# 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.
Without 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. 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.