Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 100 additions & 36 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,33 @@ on:
push:
branches:
- main
- dev
pull_request: {}
pull_request:
types: [opened, reopened, synchronize]
# Clean up the staging environment when a PR is closed
# Use pull_request_target to also run when the PR has merge conflicts
pull_request_target:
types: [closed]

Comment on lines +7 to 12
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using pull_request_target for the cleanup job poses a security risk. The pull_request_target trigger runs in the context of the base repository with access to all secrets, even for PRs from forks. While the comment mentions using it "to also run when the PR has merge conflicts", this creates a security vulnerability where malicious actors could potentially access secrets through carefully crafted PRs. A safer approach would be to use the standard pull_request trigger with appropriate handling for merge conflicts, or to add explicit checks that verify the PR is from the same repository (similar to the check on line 188).

Suggested change
types: [opened, reopened, synchronize]
# Clean up the staging environment when a PR is closed
# Use pull_request_target to also run when the PR has merge conflicts
pull_request_target:
types: [closed]
types: [opened, reopened, synchronize, closed]
# Clean up the staging environment when a PR is closed

Copilot uses AI. Check for mistakes.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
actions: write
contents: read

env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
# Change this if you want to deploy to a different org
FLY_ORG: personal
jobs:
lint:
name: ⬣ ESLint
runs-on: ubuntu-22.04
if: ${{ github.event.action != 'closed' }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -42,10 +50,10 @@ jobs:
typecheck:
name: ʦ TypeScript
runs-on: ubuntu-22.04
if: ${{ github.event.action != 'closed' }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -69,10 +77,10 @@ jobs:
vitest:
name: ⚡ Vitest
runs-on: ubuntu-22.04
if: ${{ github.event.action != 'closed' }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -93,11 +101,11 @@ jobs:
playwright:
name: 🎭 Playwright
runs-on: ubuntu-22.04
if: ${{ github.event.action != 'closed' }}
timeout-minutes: 60
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: 🏄 Copy test env vars
run: cp .env.example .env

Expand Down Expand Up @@ -146,8 +154,7 @@ jobs:
container:
name: 📦 Prepare Container
runs-on: ubuntu-24.04
# only prepare container on pushes
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
Expand All @@ -164,37 +171,106 @@ jobs:
- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/[email protected]

- name: 📦 Build Staging Container
if: ${{ github.ref == 'refs/heads/dev' }}
- name: 📦 Build Production Container
run: |
flyctl deploy \
--build-only \
--push \
--image-label ${{ github.sha }} \
--build-arg COMMIT_SHA=${{ github.sha }} \
--app ${{ steps.app_name.outputs.value }}-staging
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
--app ${{ steps.app_name.outputs.value }}

- name: 📦 Build Production Container
if: ${{ github.ref == 'refs/heads/main' }}
deploy-staging:
name: 🚁 Deploy staging app for PR
runs-on: ubuntu-24.04
# Only run for PRs from the same repository (skip forks)
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
outputs:
url: ${{ steps.deploy.outputs.url }}
environment:
name: staging
url: ${{ steps.deploy.outputs.url }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: '50'
- name: 👀 Read app name
uses: SebRollen/[email protected]
id: app_name
with:
file: 'fly.toml'
field: 'app'

- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/[email protected]

# Inspired by https://github.com/superfly/fly-pr-review-apps/blob/main/entrypoint.sh
- name: 🚁️ Deploy PR app to Fly.io
id: deploy
if: ${{ env.FLY_API_TOKEN }}
run: |
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
FLY_REGION=$(flyctl config show | jq -r '.primary_region')

# Create app if it doesn't exist
if ! flyctl status --app "$FLY_APP_NAME"; then
# change org name if needed
flyctl apps create $FLY_APP_NAME --org $FLY_ORG
flyctl secrets --app $FLY_APP_NAME set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The staging deployment is missing INTERNAL_COMMAND_TOKEN in the secrets setup. This secret is required by the application (defined in app/utils/env.server.ts) for internal cache synchronization. Add INTERNAL_COMMAND_TOKEN=$(openssl rand -hex 32) to the flyctl secrets set command on line 221.

Suggested change
flyctl secrets --app $FLY_APP_NAME set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32)
flyctl secrets --app $FLY_APP_NAME set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32) INTERNAL_COMMAND_TOKEN=$(openssl rand -hex 32)

Copilot uses AI. Check for mistakes.
flyctl consul attach --app $FLY_APP_NAME
# Don't log the created tigris secrets!
flyctl storage create --app $FLY_APP_NAME --name epic-stack-$FLY_APP_NAME --yes > /dev/null 2>&1
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The staging deployment job creates the Fly app but never creates the required volume for SQLite data storage. The fly.toml file defines a mount that expects a volume named "data" (lines 12-14 of fly.toml), but unlike the production setup in remix.init/index.mjs (line 190) and the deployment documentation (docs/deployment.md line 90), no volume is created here. This will cause the deployment to fail because the mount point cannot be satisfied. Add a volume creation command before the deploy step, for example: flyctl volumes create data --region $FLY_REGION --size 1 --yes --app $FLY_APP_NAME

Suggested change
flyctl storage create --app $FLY_APP_NAME --name epic-stack-$FLY_APP_NAME --yes > /dev/null 2>&1
flyctl storage create --app $FLY_APP_NAME --name epic-stack-$FLY_APP_NAME --yes > /dev/null 2>&1
flyctl volumes create data --region $FLY_REGION --size 1 --yes --app $FLY_APP_NAME

Copilot uses AI. Check for mistakes.
fi

flyctl secrets --app $FLY_APP_NAME set SENTRY_DSN=${{ secrets.SENTRY_DSN }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}

Comment on lines +227 to +228
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting optional secrets that may be undefined could result in empty string values being passed to Fly, which may differ from the intended behavior of having them undefined. When GitHub secrets.SENTRY_DSN or secrets.RESEND_API_KEY are not set, they evaluate to empty strings in the flyctl command, which means these environment variables will be set to empty strings rather than being undefined. Consider conditionally setting these secrets only when they're actually defined, or handle empty string values appropriately in the application code.

Suggested change
flyctl secrets --app $FLY_APP_NAME set SENTRY_DSN=${{ secrets.SENTRY_DSN }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
if [ -n "${{ secrets.SENTRY_DSN }}" ]; then
flyctl secrets --app "$FLY_APP_NAME" set SENTRY_DSN=${{ secrets.SENTRY_DSN }}
fi
if [ -n "${{ secrets.RESEND_API_KEY }}" ]; then
flyctl secrets --app "$FLY_APP_NAME" set RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
fi

Copilot uses AI. Check for mistakes.
flyctl deploy \
--build-only \
--push \
--ha=false \
--regions $FLY_REGION \
--vm-size shared-cpu-1x \
--env APP_ENV=staging \
--env ALLOW_INDEXING=false \
--app $FLY_APP_NAME \
--image-label ${{ github.sha }} \
--build-arg COMMIT_SHA=${{ github.sha }} \
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
--app ${{ steps.app_name.outputs.value }}
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

echo "url=https://$FLY_APP_NAME.fly.dev" >> $GITHUB_OUTPUT

cleanup-staging:
name: 🧹 Cleanup staging app
runs-on: ubuntu-24.04
if: ${{ github.event.action == 'closed' }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: 👀 Read app name
uses: SebRollen/[email protected]
id: app_name
with:
file: 'fly.toml'
field: 'app'

- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/[email protected]

- name: 🧹 Cleanup resources
if: ${{ env.FLY_API_TOKEN }}
run: |
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
flyctl storage destroy epic-stack-$FLY_APP_NAME --yes || true
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup job doesn't delete the persistent volume created for each staging app. When the staging app is destroyed, orphaned volumes will remain and accumulate costs. The cleanup should include volume deletion before destroying the app. Add a command like: flyctl volumes list --app "$FLY_APP_NAME" --json | jq -r '.[].id' | xargs -r -I {} flyctl volumes destroy {} --app "$FLY_APP_NAME" -y || true

Suggested change
flyctl storage destroy epic-stack-$FLY_APP_NAME --yes || true
flyctl storage destroy epic-stack-$FLY_APP_NAME --yes || true
flyctl volumes list --app "$FLY_APP_NAME" --json | jq -r '.[].id' | xargs -r -I {} flyctl volumes destroy {} --app "$FLY_APP_NAME" -y || true

Copilot uses AI. Check for mistakes.
flyctl apps destroy "$FLY_APP_NAME" -y || true
deploy:
name: 🚀 Deploy
name: 🚀 Deploy production
runs-on: ubuntu-24.04
needs: [lint, typecheck, vitest, playwright, container]
# only deploy on pushes
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
environment:
name: production
url: https://${{ steps.app_name.outputs.value }}.fly.dev
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
Expand All @@ -211,19 +287,7 @@ jobs:
- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/[email protected]

- name: 🚀 Deploy Staging
if: ${{ github.ref == 'refs/heads/dev' }}
run: |
flyctl deploy \
--image "registry.fly.io/${{ steps.app_name.outputs.value }}-staging:${{ github.sha }}" \
--app ${{ steps.app_name.outputs.value }}-staging
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

- name: 🚀 Deploy Production
if: ${{ github.ref == 'refs/heads/main' }}
run: |
flyctl deploy \
--image "registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.sha }}"
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
21 changes: 15 additions & 6 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,24 @@ migrations.
## Seeding Production

In this application we have Role-based Access Control implemented. We initialize
the database with `admin` and `user` roles with appropriate permissions.
the database with `admin` and `user` roles with appropriate permissions. This is
done in the `migration.sql` file that's included in the template.

This is done in the `migration.sql` file that's included in the template. If you
need to seed the production database, modifying migration files manually is the
recommended approach to ensure it's reproducible.
For staging we create a new database for each PR. To make sure that this
database is already filled with some seed data we manually run the following
command:

```sh
npx prisma db execute --file ./prisma/seed.staging.sql --url $DATABASE_URL
```

If you need to seed the production database, modifying migration files manually
is the recommended approach to ensure it's reproducible.

The trick is not all of us are really excited about writing raw SQL (especially
if what you need to seed is a lot of data), so here's an easy way to help out:
if what you need to seed is a lot of data). You could look at `seed.staging.sql`
for inspiration or create a custom sql migration file with the following steps.
You can also use these steps to modify the seed.staging.sql file to your liking.

1. Create a script very similar to our `prisma/seed.ts` file which creates all
the data you want to seed.
Expand Down Expand Up @@ -300,7 +310,6 @@ You've got a few options:
re-generating the migration after fixing the error.
3. If you do care about the data and don't have a backup, you can follow these
steps:

1. Comment out the
[`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37).

Expand Down
48 changes: 48 additions & 0 deletions docs/decisions/047-pr-staging-environments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Per-PR Staging Environments

Date: 2025-12-24

Status: accepted

## Context

The Epic Stack previously used a single shared staging environment deployed from the `dev` branch. This approach created several challenges for teams working with multiple pull requests:

- **Staging bottleneck**: Only one PR could be properly tested in the staging environment at a time, making parallel development difficult.
- **Unclear test failures**: When QA testing failed, it was hard to determine if the failure was from the specific PR being tested or from other changes that had been deployed to the shared staging environment.
- **Serial workflow**: Teams couldn't perform parallel quality assurance, forcing them to coordinate who could use staging at any given time.
- **Extra setup complexity**: During initial deployment, users had to create and configure a separate staging app with its own database, secrets, and resources.

Fly.io provides native support for PR preview environments through their `fly-pr-review-apps` GitHub Action, which can automatically create, update, and destroy ephemeral applications for each pull request.

This pattern is common in modern deployment workflows (Vercel, Netlify, Render, etc.) and provides isolated environments for testing changes before they reach production.

## Decision

We've decided to replace the single shared staging environment with per-PR staging environments using Fly.io's PR review apps feature. Each pull request now:

- Gets its own isolated Fly.io application (e.g., `app-name-pr-123`)
- Automatically provisions all necessary resources (SQLite volume, Tigris object storage, Consul for LiteFS)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decision document claims that PR environments "Automatically provisions all necessary resources (SQLite volume, Tigris object storage, Consul for LiteFS)" but the workflow implementation at lines 218-225 of .github/workflows/deploy.yml is missing the volume creation step. This creates a discrepancy between what the documentation promises and what actually happens.

Copilot uses AI. Check for mistakes.
- Generates and stores secrets (SESSION_SECRET, HONEYPOT_SECRET)
- Seeds the database with test data for immediate usability
- Provides a direct URL to the deployed app in the GitHub PR interface
- Automatically cleans up all resources when the PR is closed

Staging environment secrets are now managed as GitHub environment secrets and passed to Fly in Github Actions.

The `dev` branch and its associated staging app have been removed from the deployment workflow. Production deployments continue to run only on pushes to the `main` branch.

## Consequences

**Positive:**

- **Isolated testing**: Each PR has its own complete environment, making it clear which changes caused any issues
- **Simplified onboarding**: New users only need to set up one production app, not both production and staging
- **Better reviews**: Reviewers (including non-technical stakeholders) can click a link to see and interact with changes before merging
- **Automatic cleanup**: Resources are freed when PRs close, reducing infrastructure costs
- **Realistic testing**: Each PR tests the actual deployment process, catching deployment-specific issues early

**Negative:**

- **Increased resource usage during development**: Each open PR consumes Fly.io resources (though they're automatically cleaned up)

Loading
Loading