As a DevOps engineer, one frontend problem has always felt unnecessarily expensive to me:
You build once for staging, then rebuild again for production because the frontend baked environment variables into the bundle.
Same app. Same code. Different environment. Another build.
That works, but it breaks one of the cleanest deployment ideas we have: build once, promote the same artifact everywhere.
So I built clientshell.
It lets you inject public runtime config into already-built frontend apps at container startup, without rebuilding for every environment.
Docs: https://yonigofman.github.io/clientshell/
The problem
Most frontend tooling treats environment variables as build-time values.
That means if your app needs a different API URL, feature flag, analytics key, or public tenant setting in another environment, the artifact changes. So your pipeline ends up doing this:
- build for dev
- build for staging
- build for prod
From a DevOps point of view, that is friction.
It makes deployments slower, introduces more room for drift, and weakens the "immutable artifact" model that works so well in modern delivery pipelines.
What I wanted instead
I wanted a flow like this:
- Build the frontend once
- Ship the same static bundle everywhere
- Inject environment-specific public config at runtime
- Keep the frontend DX typed and predictable
That is what clientshell does.
What clientshell is
clientshell is a small toolchain for runtime public config in frontend apps.
It has a few pieces:
-
@clientshell/corefor defining and reading typed public config -
@clientshell/zodif you want to define the schema with Zod -
@clientshell/vite,@clientshell/webpack, and@clientshell/rollupplugins -
@clientshell/clifor manifest generation and validation - a fast Go-based injector for container startup
The idea is simple:
- define a schema in TypeScript
- generate a manifest during build
- inject values into
/env-config.jsat runtime - read them in the browser with a typed API
Why this matters in DevOps
This project came from a DevOps mindset more than a frontend one.
The main benefit is not just convenience. It is deployment discipline.
With runtime injection:
- the same image can go to staging and production
- environment differences move to runtime config, not build logic
- CI pipelines get simpler
- rollbacks are cleaner
- supply chain and provenance story improves because you publish fewer environment-specific artifacts
In other words: your frontend starts behaving more like a real deployable artifact.
A simple example
Define your public config shape once:
import { defineSchema, string, boolean } from "@clientshell/core";
export const clientEnvSchema = defineSchema({
API_URL: string({ required: true }),
ENABLE_BETA: boolean({ defaultValue: false }),
});
Use it in your app:
import { readEnvFromShape } from "@clientshell/core";
import { clientEnvSchema } from "./env.schema";
const env = readEnvFromShape(clientEnvSchema);
console.log(env.API_URL);
At build time, clientshell creates a manifest.
At runtime, the injector reads environment variables and serves the generated config to the browser.
No rebuild required.
Why not just use window.__ENV__?
You can. A lot of teams do.
But that usually turns into custom glue code, ad hoc scripts, inconsistent schema handling, and no real validation story.
What I wanted was something more structured:
- typed schema
- clear manifest format
- bundler support
- runtime injection
- container-friendly behavior
- CLI support for validation and generation
So instead of one more shell-script-on-top-of-nginx setup, this became a small, focused toolchain.
Where it fits
I think clientshell is especially useful for teams that:
- deploy the same frontend artifact across multiple environments
- use Docker heavily
- want stronger separation between build and deploy phases
- care about typed public config
- are tired of environment-specific frontend rebuilds
If you are running Vite, Webpack, or Rollup-based apps and you want cleaner promotion pipelines, this is exactly the use case.
Docker example
The runtime model works especially well with containers.
A typical flow looks like this:
# 1. Build the app
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN pnpm install && pnpm build
# 2. Runtime image
FROM clientshell
COPY --from=builder /app/dist /app/dist
Then at runtime:
docker run -p 9000:9000 \
-e CLIENTSHELL_PORT=9000 \
-e CLIENT_API_URL=https://api.example.com \
my-app
That is the piece I like most.
You stop coupling deployment environment to bundle generation.
A note on DX
I did not want this to feel like a DevOps-only hack bolted onto frontend apps.
So the project keeps frontend ergonomics in mind too:
- schema is typed
- browser-side API is clean
- Vite integration is straightforward
- there is a Zod adapter if that is your preferred pattern
- local development still works with stubbed config
So the frontend team does not need to fight the delivery model just because the platform team wants cleaner deployments.
Why I built it
Honestly, because I got tired of treating frontend config as if it had to be frozen at build time.
Backend and platform engineers have had better separation between build and runtime for a long time. Frontend delivery often lags behind there.
I wanted something small, explicit, typed, and deployment-friendly.
That became clientshell.
If this sounds familiar
If you have ever thought:
"Why am I rebuilding the exact same frontend just to change one public URL?"
then this project is for you.
Project
GitHub: https://github.com/yonigofman/clientshell
Docs: https://yonigofman.github.io/clientshell/
Packages:
Top comments (0)