Monorepo Patterns
Hush is designed specifically for monorepos where different packages need different subsets of your environment variables.
The Problem
In a typical monorepo, you might have:
Directorypackages/
Directorymobile/ # Expo app - needs EXPO_PUBLIC_* vars
- …
Directoryweb/ # Next.js - needs NEXT_PUBLIC_* vars
- …
Directoryapi/ # Cloudflare Worker - needs API keys
- …
Directoryshared/ # Shared library - might need some config
- …
- .env # All your secrets in one place
Each package needs different variables:
- Mobile app: Only client-safe
EXPO_PUBLIC_*variables - Web app: Only client-safe
NEXT_PUBLIC_*variables - API: Server secrets, but not client variables
- Root: Everything, for scripts and local development
Manually copying .env files is error-prone. Committing different subsets is a maintenance nightmare.
Two Approaches: Push vs Pull
Hush supports two architectures for distributing secrets. They solve different problems—use the right tool for the job.
| Approach | Best For | Example |
|---|---|---|
| Push (include/exclude) | Pattern-based filtering, auto-flow | ”All NEXT_PUBLIC_* → web app” |
| Pull (subdirectory templates) | Transformation, renaming, defaults | ”Rename API_URL → EXPO_PUBLIC_API_URL” |
Pull-Based Architecture
Each package defines what it needs in its own .env file, referencing root secrets.
1. Define Root Secrets
Store canonical secrets in your root .env files. No framework prefixes needed.
# Root .env (encrypted)API_URL=https://api.example.comSTRIPE_KEY=sk_test_xxxDATABASE_URL=postgres://...2. Create Package Templates
Create .env files in your packages that reference the root secrets. These files are committed to git because they are just templates/mappings, not secrets themselves.
packages/web/.env:
# Pull from root and add Next.js prefixNEXT_PUBLIC_API_URL=${API_URL}NEXT_PUBLIC_STRIPE_KEY=${STRIPE_KEY}
# Local defaultPORT=${PORT:-3000}packages/mobile/.env:
# Pull from root and add Expo prefixEXPO_PUBLIC_API_URL=${API_URL}EXPO_PUBLIC_STRIPE_KEY=${STRIPE_KEY}packages/api/.env:
# Pull server secretsDATABASE_URL=${DATABASE_URL}STRIPE_KEY=${STRIPE_KEY}
# Local defaultPORT=${PORT:-8787}3. Run
When you run hush run inside a package, it automatically resolves these references:
cd packages/webhush run -- npm devThe web app receives NEXT_PUBLIC_API_URL with the decrypted value from the root secrets.
Push-Based Architecture
Use hush.yaml targets with include/exclude patterns when you want automatic filtering without maintaining templates:
sources: shared: .env development: .env.development production: .env.production
targets: - name: root path: . format: dotenv
- name: mobile path: ./packages/mobile format: dotenv include: - EXPO_PUBLIC_*
- name: web path: ./packages/web format: dotenv include: - NEXT_PUBLIC_*When push works best:
- Adding
NEXT_PUBLIC_FOOat root auto-flows to web—no template update needed - Centralized control: one file shows all routing rules
- Simple filtering: “everything matching X goes to Y”
When push doesn’t work:
- Cannot rename:
API_URLstaysAPI_URL, can’t becomeEXPO_PUBLIC_API_URL - Cannot add defaults: no
${PORT:-3000}syntax - Cannot compose: no
${HOST}:${PORT}combinations
Combining Both Approaches
You can use push for some targets and pull for others in the same project:
# hush.yaml - push for simple targetstargets: - name: web path: ./apps/web include: [NEXT_PUBLIC_*] # Auto-flow, no transformation needed# apps/mobile/.env - pull for targets needing transformationEXPO_PUBLIC_API_URL=${API_URL} # Rename requiredEXPO_PUBLIC_STRIPE_KEY=${STRIPE_KEY} # Rename requiredPORT=${PORT:-8081} # Default valueCommon Patterns
Client vs Server Split
The most common pattern - keep client-safe variables separate from server secrets:
targets: # Client apps get public variables only - name: app path: ./packages/app format: dotenv include: - EXPO_PUBLIC_* - NEXT_PUBLIC_* - VITE_*
# Server gets everything except public variables - name: api path: ./packages/api format: wrangler exclude: - EXPO_PUBLIC_* - NEXT_PUBLIC_* - VITE_*Multiple APIs
When you have multiple backend services:
targets: - name: auth-api path: ./packages/auth format: wrangler include: - AUTH_* - JWT_* - DATABASE_*
- name: payments-api path: ./packages/payments format: wrangler include: - STRIPE_* - DATABASE_*Shared Configuration
Some variables need to be everywhere:
targets: # Every package gets APP_* variables - name: mobile path: ./packages/mobile format: dotenv include: - APP_* - EXPO_PUBLIC_*
- name: web path: ./packages/web format: dotenv include: - APP_* - NEXT_PUBLIC_*
- name: api path: ./packages/api format: wrangler include: - APP_* - API_* - DATABASE_*Environment-Specific URLs
Use interpolation for environment-specific base URLs:
# .env (shared)EXPO_PUBLIC_API_URL=${API_BASE}/v1
API_BASE=http://localhost:8787
# .env.productionAPI_BASE=https://api.yourapp.comThis gives you:
- Development:
EXPO_PUBLIC_API_URL=http://localhost:8787/v1 - Production:
EXPO_PUBLIC_API_URL=https://api.yourapp.com/v1
Real-World Example
Here’s a complete example for a typical full-stack monorepo:
Directorypackages/
Directorymobile/ # Expo React Native app
- …
Directoryweb/ # Next.js marketing site
- …
Directorydashboard/ # Vite admin dashboard
- …
Directoryapi/ # Cloudflare Worker API
- …
Directoryshared/ # Shared TypeScript library
- …
- .env
- .env.development
- .env.production
- hush.yaml
Environment Files
# .env (shared secrets)DATABASE_URL=postgres://user:pass@host/dbSTRIPE_SECRET_KEY=sk_xxxSTRIPE_WEBHOOK_SECRET=whsec_xxxSENDGRID_API_KEY=SG.xxx
# API URLs (interpolated)EXPO_PUBLIC_API_URL=${API_BASE}/v1NEXT_PUBLIC_API_URL=${API_BASE}/v1VITE_API_URL=${API_BASE}/v1
# App configAPP_NAME=MyAppAPP_VERSION=1.0.0API_BASE=http://localhost:8787EXPO_PUBLIC_ENV=developmentNEXT_PUBLIC_ENV=developmentVITE_ENV=developmentDEBUG=trueAPI_BASE=https://api.myapp.comEXPO_PUBLIC_ENV=productionNEXT_PUBLIC_ENV=productionVITE_ENV=productionDEBUG=falseConfiguration
sources: shared: .env development: .env.development production: .env.production
targets: # Root for scripts - name: root path: . format: dotenv
# Expo mobile app - name: mobile path: ./packages/mobile format: dotenv include: - EXPO_PUBLIC_* - APP_NAME - APP_VERSION
# Next.js marketing site - name: web path: ./packages/web format: dotenv include: - NEXT_PUBLIC_* - APP_NAME
# Vite admin dashboard - name: dashboard path: ./packages/dashboard format: dotenv include: - VITE_* - APP_NAME
# Cloudflare Worker API - name: api path: ./packages/api format: wrangler exclude: - EXPO_PUBLIC_* - NEXT_PUBLIC_* - VITE_*Workflow
Development
# Run from root with all secretshush run -- npm run dev
# Run mobile app with filtered secretshush run -t mobile -- npm run start
# Run API with its secrets onlyhush run -t api -- wrangler devDeployment
# Build with production secretshush run -e production -- npm run build
# Push secrets to Cloudflare Workershush pushCI/CD
In your GitHub Actions or CI pipeline:
- name: Setup secrets run: | mkdir -p $HOME/.config/sops/age echo "${{ secrets.AGE_SECRET_KEY }}" > $HOME/.config/sops/age/key.txt
- name: Build run: npx hush run -e production -- npm run build
- name: Deploy API secrets run: npx hush pushVerifying Your Setup
Use hush status to verify your configuration:
hush statusThis shows:
- Which source files are configured
- Encryption status of each file
- Target distribution with their filters