Practical CI/CD for a pnpm monorepo with GitHub Actions (no Turborepo required)
Storyie is a pnpm monorepo: Next.js on the web, Expo on mobile, more than ten shared packages in between. The CI/CD surface covers web deploys to AWS via SST, Expo builds through EAS to TestFlight and Firebase App Distribution, Supabase migrations, daily smoke tests, weekly performance checks, and a monthly Apple OAuth secret rotation. Eleven workflows in total.
We built all of it on pnpm workspace and GitHub Actions with no Turborepo, no Nx. This post is the full design writeup — what we built, why we made each call, and what we learned operating it.
TL;DR
- Eleven workflows, zero Turborepo — pnpm workspace + paths filters is enough for a project at this scale.
- dotenvx encrypts
.envfiles in the repo; GitHub Secrets holds only one or two decryption keys per environment. - A composite action deduplides Node/pnpm/EAS setup across iOS and Android jobs.
- A daily Playwright smoke test catches production failures that CI never sees: certificate expiry, infra drift.
- Apple Sign-In client secrets rotate automatically on a monthly schedule before they can expire.
packages/**always triggers both web and mobile workflows — no exceptions.
Workflow | Trigger | Purpose |
|---|---|---|
Web Test | push / PR ( | Type check + lint + tests |
Deploy Web Staging | push to | SST deploy to staging |
Deploy Web Production | push to | SST deploy to production |
Expo Firebase Distribution | push to | iOS → TestFlight, Android → Firebase |
Supabase Migration | push ( | Apply DB migrations |
Web Smoke Test | daily 9:00 UTC | Playwright against production |
Web Performance | manual | Lighthouse CI |
Rotate Apple OAuth Secret | 1st of every month | Refresh Apple Sign-In client secret |
Monorepo layout
apps/
web/ # Next.js (SST deploy to AWS)
expo/ # Expo (EAS Build → TestFlight / Firebase)
packages/
database/ # Drizzle ORM + Supabase migrations
lexical-*/ # Rich-text editor (Lexical)
subscription/ # Plan-limit logic
theme/ # Cross-platform design tokens
...pnpm-workspace.yaml is two lines:
packages:
- apps/*
- packages/*pnpm's catalog feature pins dependency versions workspace-wide. Writing catalog: in any package.json resolves to whatever is declared in the catalog, so renovate bumps land in one place rather than in every package.json that references the same library.
Why no Turborepo
Turborepo (and Nx) are the standard recommendation for monorepo CI/CD, and for large teams with build times measured in minutes they earn their keep. We looked at both and skipped them for three reasons.
The workflows are already decomposed. Web tests, web deploy, Expo build, and DB migration are separate workflow files. Paths filters give us per-job control at the same granularity that a task graph would. There is nothing for Turborepo to orchestrate that we are not already orchestrating with native Actions features.
Self-hosted runners negate the cache argument. Turborepo's headline feature is remote caching — build outputs stored remotely so jobs skip unchanged packages. Our self-hosted runner keeps node_modules and the pnpm store on disk between runs, so pnpm install completes in seconds and build times are already fast. Remote cache would add a network round trip and a service to maintain in exchange for no measurable gain.
Complexity has a maintenance cost. turbo.json is another config file to understand, debug, and keep in sync with the workspace. On a small team, every layer of indirection is weight you carry forever. We wanted a pipeline where any engineer — including someone who has never touched this repo — could read a workflow YAML and understand exactly what it does.
The result: eleven workflows, no turbo.json, no complaints.
Decision 1: self-hosted runner
GitHub-hosted runners (ubuntu-latest) work fine, but Storyie uses a self-hosted runner for the web and Expo workflows.
Advantages:
- pnpm store persists between runs —
pnpm installon a warm runner takes a few seconds rather than a minute or more. - Playwright browser binaries are already installed; no download on every run.
- No Actions minutes consumed (relevant for private repos on free-tier plans).
Disadvantages:
- The machine needs to be up and reachable when a workflow fires.
- Security is your problem. For public repositories, self-hosted runners are a significant attack surface. For private repositories with controlled contributors, the risk profile is acceptable.
For a private repository owned by a small team, running the CI runner on a machine you already own is a straightforward win on cost and speed.
Decision 2: dotenvx for encrypted secrets in the repo
Managing environment variables for CI/CD is the part of DevOps that causes the most silent failures. Values get stale in GitHub Secrets without anyone noticing. Variables go undocumented. Staging and production values mix.
Storyie uses dotenvx to encrypt .env.production files and commit them to the repository. The decryption key goes into GitHub Secrets. At deploy time:
- name: Decrypt .env.production files with dotenvx
run: |
npx dotenvx decrypt -f .env.production
npx dotenvx decrypt -f apps/web/.env.production
env:
DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }}The design pays off in three ways.
The variable list is in the repo. Key names are visible in the encrypted file. Any developer can check what variables a workflow needs by reading the .env.production in version control — no cross-referencing a secrets UI.
GitHub Secrets holds one key per environment. Not fifteen secrets, one. Adding a new variable means updating the encrypted .env file; no GitHub settings change required.
Staging and production are physically separate files. Not naming conventions, not prefixes — different files, different keys. To prevent environment bleed, the production deploy workflow explicitly removes staging-environment files before decrypting production ones:
- name: Setup dotenvx
run: |
rm -f .env .env.local
echo "Removed staging .env files to ensure production configuration is used"This one step has prevented "deployed to production with staging database URL" more than once.
Decision 3: composite action for Expo build setup
iOS and Android are separate jobs. Both need: pnpm setup, Node.js setup with pnpm cache, EAS CLI setup via expo/expo-github-action, and our own auth steps. Before composite actions, that was duplicated step-for-step in each job definition.
We moved shared setup into .github/actions/setup/action.yaml:
name: "setup common"
runs:
using: composite
steps:
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Setup Expo and EAS
uses: expo/expo-github-action@v8
# ...additional shared stepsEach job calls it with one line:
- uses: ./.github/actions/setupWhen the Node version changes or EAS CLI needs an update, one edit propagates to both jobs. The per-job YAML shrinks to just the build step itself.
Decision 4: daily smoke test against production
A deploy pipeline tells you whether the code passed tests before deployment. It says nothing about whether production is healthy right now. TLS certificates expire without any code change. Infrastructure configuration can drift after a platform update. Third-party services go down.
We run a Playwright smoke test against the live production URL every morning:
on:
schedule:
- cron: "0 9 * * *"When the test fails, a GitHub Issue is automatically created:
- name: Create issue on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.create({
title: `Smoke test failure - ${date}`,
labels: ['smoke-test-failure'],
body: `## Smoke Test Failure\n\n**Target**: ${process.env.E2E_BASE_URL}...`,
});The issue includes the target URL, the test that failed, and a timestamp. Triage starts with context rather than from zero. This has surfaced certificate expiry and IAM policy changes — both invisible to pre-deploy CI.
Decision 5: automated Apple OAuth secret rotation
Apple Sign-In client secrets expire after at most six months. Forgetting to rotate one means Apple authentication silently stops working — and the failure mode is a 401 at login time for every user who tries Sign in with Apple.
We schedule a monthly workflow:
on:
schedule:
- cron: "0 0 1 * *" # 1st of every monthThe workflow generates a new JWT, calls the Supabase Auth configuration API to update the secret, and processes both staging and production in parallel using a matrix strategy:
strategy:
matrix:
environment: [development, production]This is the category of task that is easy to automate and catastrophic to forget. Anything that expires silently — secrets, certificates, tokens — belongs on a schedule.
Decision 6: paths filter granularity
paths filters are how we limit which workflows run on any given push. The risk is over-filtering: if packages/** is not in a workflow's paths list, a change to a shared package like @storyie/subscription won't trigger that workflow's tests, and a regression ships quietly.
Our rule: any workflow affected by shared package changes includes packages/** in its paths filter. No exceptions. We also include the workflow file itself, so a change to workflow YAML is immediately tested:
paths:
- "apps/web/**"
- "packages/**"
- "sst.config.ts"
- ".github/workflows/deploy-web-prd.yml"We also trigger on sst.config.ts changes for deploy workflows, because a change to the SST infrastructure definition can affect the deployment outcome even if no application code changed.
The general principle: false positives (running a workflow when nothing changed) cost compute time. False negatives (not running a workflow when something changed) let bugs into production. We optimize against false negatives.
pnpm workspace operational notes
Always build packages first
Shared packages are TypeScript source; they have no pre-built output. If the app build or type-check runs before pnpm build:packages, imports from shared packages will fail with missing-module errors. Every workflow that touches the apps has this as the first application step:
- name: Build packages
run: pnpm build:packages
- name: Run type check
run: pnpm type-checkFrozen lockfile in CI
- name: Install dependencies
run: pnpm install --frozen-lockfile--frozen-lockfile fails the job if pnpm-lock.yaml is out of sync with any package.json. This catches the common mistake of updating a package.json and committing without updating the lockfile — a mistake that otherwise produces a runtime failure hours into the workflow.
Takeaways
Six things we would tell ourselves at the start of this:
- Paths filters — go wide. The cost of an extra run is minutes of compute. The cost of a skipped run is a production regression.
packages/**goes in every workflow that touches apps, always. - dotenvx makes secret management tractable. One decryption key per environment in GitHub Secrets; the rest in encrypted files in the repo where they are auditable and versioned.
- Self-hosted runners pay off early for private repos. The pnpm store cache alone cuts install time substantially; no additional caching infrastructure required.
- A smoke test catches what CI cannot. Certificate expiry, infra drift, third-party outages — these are invisible to pre-deploy pipelines and visible only to something that polls production on a schedule.
- Automate anything that expires silently. If a secret or certificate can stop working without any code change, put it on a schedule before it fails in production.
- Turborepo is not a requirement. For a monorepo with naturally decomposed workflows and a self-hosted runner, pnpm workspace and GitHub Actions are sufficient. The right tool is the simplest one that covers your actual constraints.
The larger point: CI/CD design is less about which orchestration tool you pick and more about knowing what you need to automate and what is safe to leave manual. Start with the failure modes, then build the automation to prevent them.
Related Posts
- Building a Monorepo with pnpm and TypeScript — workspace conventions, dependency catalog, and cross-package rules
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — the shared package design that makes the same code run on web and mobile - Deploying Next.js 16 to AWS with SST — the SST deploy step that these workflows drive
Try Storyie
All of this CI/CD infrastructure serves one product: a diary app that works the same on web and mobile. If you want to see the end result, write something at storyie.com and open it on the iOS app. The pipeline above is what keeps it working.