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 |
| Deploy-time freeze | Apps without a framework integration baking values (Elysia, Hono, Fastify), where you want config frozen with the release instead of resolved on every container boot | Sometimes. Only in a builder stage or CI |
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 resolves and injects env during build (Next.js with freezeIntoBundle: true, Vite SSR in resolved-env mode, etc.). 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.
Use when no framework integration is baking values at build time, and you want config resolved once per release rather than on every container boot. varlock freeze resolves everything at deploy time and encrypts it; the runtime image needs no varlock CLI, no .env files, and no resolver credentials, so this works in distroless.
FROM oven/bun:1 AS builderWORKDIR /appCOPY . .RUN bun install --frozen-lockfileRUN bun build ./src/index.ts --compile --outfile server --no-compile-autoload-dotenv# .env files and resolver credentials exist only in this stageRUN --mount=type=secret,id=varlock_key \ _VARLOCK_ENV_KEY=$(cat /run/secrets/varlock_key) APP_ENV=production bunx varlock freeze
FROM gcr.io/distroless/base-debian13:nonrootWORKDIR /appCOPY --from=builder --chown=nonroot:nonroot /app/server /app/.varlock-frozen-env ./ENV _VARLOCK_USE_FROZEN_ENV=1CMD ["/app/server"]_VARLOCK_USE_FROZEN_ENV=1 is what makes varlock boot from the file (it is never read otherwise), and a missing file is then an error rather than a silent fall back to normal resolution. Set _VARLOCK_ENV_KEY in the runtime environment so the file can be decrypted. The frozen file is written 0600, so --chown is needed for the nonroot user to read it; a distroless image has no runtime to run plain JS, which is why this example compiles a standalone binary (see Elysia).
If you cannot copy a file into the image (a Compose file that pulls a tag it does not rebuild, say), varlock freeze --out - writes the same payload to stdout and you carry it in one variable instead:
services: app: environment: __VARLOCK_ENV: ${__VARLOCK_ENV:?run `varlock freeze --out -` first} _VARLOCK_USE_INJECTED_ENV: "1" _VARLOCK_ENV_KEY: ${_VARLOCK_ENV_KEY:?}That is a weaker guarantee, since the payload lives in platform config rather than inside the release. See carrying the payload in an env var for the full comparison.
Values are fixed either way: rotating a secret takes effect on your next deploy, not on the next restart.
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/varlockNext.js standalone images
Section titled “Next.js standalone images”A Next.js build with output: 'standalone' must say how its server gets the env, with the freezeIntoBundle plugin option. The build fails until it does, because the standalone output includes neither the varlock CLI nor your .env files.
export default varlockNextConfigPlugin({ freezeIntoBundle: true })(nextConfig);The env is resolved during next build and baked, encrypted, into the server output. The runtime image needs no varlock CLI and no .env files, only _VARLOCK_ENV_KEY. Pass the key to the build as a build secret so it never lands in an image layer:
FROM node:22-alpine AS builderWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfileCOPY . .RUN --mount=type=secret,id=varlock_env_key \ _VARLOCK_ENV_KEY="$(cat /run/secrets/varlock_env_key)" pnpm build
FROM node:22-alpine AS runnerWORKDIR /appENV NODE_ENV=productionCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./publicCMD ["node", "server.js"]docker build --secret id=varlock_env_key,env=_VARLOCK_ENV_KEY -t my-app .docker run -e _VARLOCK_ENV_KEY my-appValues are fixed per image: rotating a secret means rebuilding. Keys that must vary per container (a port, an instance id) go in the schema as @dynamic=boot and are set with docker run -e.
export default varlockNextConfigPlugin({ freezeIntoBundle: false })(nextConfig);The server resolves the env when the container starts, so the runtime image needs the varlock CLI, your .env files, and any resolver credentials:
FROM node:22-alpine AS builderWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfileCOPY . .RUN pnpm build
FROM node:22-alpine AS runnerRUN apk add --no-cache ca-certificates libstdc++COPY --from=ghcr.io/dmno-dev/varlock:1.6.0 /usr/local/bin/varlock /usr/local/bin/varlockWORKDIR /appENV NODE_ENV=productionCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./publicCOPY --from=builder /app/.env.schema /app/.env.production ./ENTRYPOINT ["varlock", "run", "--"]CMD ["node", "server.js"]If the CLI is missing at boot, the server exits immediately with a message naming these options, rather than serving with values from the build.
Runtime: 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:latestPID 1, signals, and zombie processes
Section titled “PID 1, signals, and zombie processes”With varlock run as the entrypoint, varlock is PID 1. It forwards SIGTERM, SIGINT, SIGHUP, and SIGQUIT to your app’s process group, waits for the app to finish its own shutdown handlers, and exits with the app’s status. docker stop and Kubernetes pod termination reach your app the same way they would if it were PID 1 itself. See Signals and exit codes for details.
What varlock does not do is reap orphaned processes. If your app spawns subprocesses that can outlive their parent (a shell that backgrounds a job, a worker pool that forks), the orphans are reparented to PID 1 and remain as zombies once they exit. If that applies to you, put a minimal init above varlock. Signal forwarding works the same either way:
RUN apk add --no-cache tiniENTRYPOINT ["tini", "--", "varlock", "run", "--"]CMD ["node", "dist/server.js"]docker run --init does the same thing without changing the image, but it is a Docker flag, so it does not apply under Kubernetes or other orchestrators.
Docker 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.