Skip to content

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.

The Solution

Define routing rules once in hush.yaml:

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_*
- name: api
path: ./packages/api
format: wrangler
exclude:
- EXPO_PUBLIC_*
- NEXT_PUBLIC_*

One hush decrypt command distributes the right secrets to every package.

Common 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.development
# .env (shared)
EXPO_PUBLIC_API_URL=${API_BASE}/v1
API_BASE=http://localhost:8787
# .env.production
API_BASE=https://api.yourapp.com

This 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

Terminal window
# .env (shared secrets)
DATABASE_URL=postgres://user:pass@host/db
STRIPE_SECRET_KEY=sk_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
SENDGRID_API_KEY=SG.xxx
# API URLs (interpolated)
EXPO_PUBLIC_API_URL=${API_BASE}/v1
NEXT_PUBLIC_API_URL=${API_BASE}/v1
VITE_API_URL=${API_BASE}/v1
# App config
APP_NAME=MyApp
APP_VERSION=1.0.0
.env.development
API_BASE=http://localhost:8787
EXPO_PUBLIC_ENV=development
NEXT_PUBLIC_ENV=development
VITE_ENV=development
DEBUG=true
.env.production
API_BASE=https://api.myapp.com
EXPO_PUBLIC_ENV=production
NEXT_PUBLIC_ENV=production
VITE_ENV=production
DEBUG=false

Configuration

hush.yaml
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

Terminal window
# Decrypt for local development
hush decrypt
# Each package now has its .env.development file
# api/ has .dev.vars for wrangler

Deployment

Terminal window
# Decrypt production secrets
hush decrypt -e production
# Push to Cloudflare Workers
hush push

CI/CD

In your GitHub Actions or CI pipeline:

- name: Decrypt secrets
run: |
echo "${{ secrets.AGE_SECRET_KEY }}" > $HOME/.config/sops/age/key.txt
npx hush decrypt -e production
- name: Deploy API
run: |
cd packages/api
wrangler deploy

Verifying Your Setup

Use hush status to verify your configuration:

Terminal window
hush status

This shows:

  • Which source files are configured
  • Encryption status of each file
  • Target distribution with their filters