Skip to content
Closed
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
104 changes: 67 additions & 37 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ on:
push:
branches:
- main
- dev
pull_request: {}
pull_request:
types: [opened, reopened, synchronize, closed]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -14,14 +14,17 @@ 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
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -45,7 +48,6 @@ jobs:
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -72,7 +74,6 @@ jobs:
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -97,7 +98,6 @@ jobs:
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4

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

Expand Down Expand Up @@ -146,8 +146,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 +163,80 @@ 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
outputs:
url: ${{ steps.deploy.outputs.url }}
environment:
name: staging
url: ${{ steps.deploy.outputs.url }}
Comment on lines +176 to +183
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The deploy-staging job doesn't have appropriate dependencies on test/lint jobs. Unlike the production deployment which requires all checks to pass (lint, typecheck, vitest, playwright, container), the staging deployment will run regardless of test results. This could deploy broken code to staging environments.

Copilot uses AI. Check for mistakes.
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]

- name: 🚁️ Deploy PR app to Fly.io
if: ${{ github.event.action != 'closed' && env.FLY_API_TOKEN }}
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The condition only checks if FLY_API_TOKEN exists but doesn't verify that this is a pull request event. This could cause issues if the action type is something other than 'closed' but FLY_API_TOKEN is set. Consider adding a check for github.event_name == 'pull_request' as well.

Copilot uses AI. Check for mistakes.
run: |
flyctl deploy \
--build-only \
--push \
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
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The inline comment suggests changing the org name if needed, but the org is configured at the top of the workflow file (line 20) with FLY_ORG set to "personal". For better maintainability, this comment should reference that configuration rather than suggesting an inline change. Also, it would be clearer to reference the environment variable: flyctl apps create $FLY_APP_NAME --org $FLY_ORG.

Suggested change
# change org name if needed
# ensure FLY_ORG (configured at the top of this workflow) is set to the desired org

Copilot uses AI. Check for mistakes.
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) SENTRY_DSN=${{ secrets.SENTRY_DSN }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
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 Dec 29, 2025

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 the volume creation step. The production setup requires a persistent volume for the SQLite database (created via fly volumes create), but the staging deployment workflow doesn't create this volume. Without it, the staging app will likely fail to start or lose data between deploys. The volume should be created if the app doesn't exist, similar to how consul and storage are set up.

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
# Create persistent volume for SQLite database
flyctl volumes create data --region "$FLY_REGION" --app "$FLY_APP_NAME" --yes

Copilot uses AI. Check for mistakes.
fi

Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

Secrets are being set during the initial app creation, but if the app already exists and the command fails (line 206), the secrets will never be set. This could happen if an app was manually created or if there was a previous failed deployment. Consider checking if secrets need to be set even for existing apps, or handle the case where the app exists but secrets might be missing.

Suggested change
# Ensure required secrets are set even if the app already existed or a previous deployment failed
REQUIRED_SECRETS=(SESSION_SECRET HONEYPOT_SECRET SENTRY_DSN RESEND_API_KEY)
MISSING_REQUIRED_SECRET=false
for secret in "${REQUIRED_SECRETS[@]}"; do
if ! flyctl secrets list --app "$FLY_APP_NAME" --json | jq -r '.[].Name' | grep -qx "$secret"; then
MISSING_REQUIRED_SECRET=true
break
fi
done
if [ "$MISSING_REQUIRED_SECRET" = true ]; then
flyctl secrets --app $FLY_APP_NAME set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32) SENTRY_DSN=${{ secrets.SENTRY_DSN }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
fi

Copilot uses AI. Check for mistakes.
flyctl deploy . \
--ha=false \ # use only one machine for staging
Comment on lines +215 to +216
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

There's a syntax error in this line. The comment is placed inline with the backslash continuation character, which will cause the shell command to fail. The comment should either be on its own line before this command or removed entirely.

Suggested change
flyctl deploy . \
--ha=false \ # use only one machine for staging
# use only one machine for staging
flyctl deploy . \
--ha=false \

Copilot uses AI. Check for mistakes.
--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 }}
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
Comment on lines +199 to +224
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The deploy-staging job doesn't populate the url output that it declares on line 180. This output is referenced in the environment section on line 183, but it's never set using the steps.deploy.outputs syntax. You should set this output, likely after the deployment completes successfully.

Copilot uses AI. Check for mistakes.

- name: 🧹 Cleanup resources when PR is closed
if: ${{ github.event.action == 'closed' && 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
flyctl apps destroy "$app" -y || true
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The variable reference "$app" is incorrect. It should be "$FLY_APP_NAME" to match the variable defined on line 229.

Suggested change
flyctl apps destroy "$app" -y || true
flyctl apps destroy "$FLY_APP_NAME" -y || true

Copilot uses AI. Check for mistakes.
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 +253,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 }}
2 changes: 1 addition & 1 deletion app/routes/users/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function UsersRoute({ loaderData }: Route.ComponentProps) {

return (
<div className="container mt-36 mb-48 flex flex-col items-center justify-center gap-6">
<h1 className="text-h1">Epic Notes Users</h1>
<h1 className="text-h1">Epic Notes Users Test</h1>
<div className="w-full max-w-[700px]">
<SearchBar status={loaderData.status} autoFocus autoSubmit />
</div>
Expand Down
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)
- 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