Skip to content

Commit b01f047

Browse files
committed
Create a staging environment deployment for pull requests
1 parent bac7bd4 commit b01f047

File tree

11 files changed

+249
-128
lines changed

11 files changed

+249
-128
lines changed

.github/workflows/deploy.yml

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ on:
33
push:
44
branches:
55
- main
6-
- dev
7-
pull_request: {}
6+
pull_request:
7+
types: [opened, reopened, synchronize, closed]
88

99
concurrency:
1010
group: ${{ github.workflow }}-${{ github.ref }}
@@ -14,14 +14,17 @@ permissions:
1414
actions: write
1515
contents: read
1616

17+
env:
18+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
19+
# Change this if you want to deploy to a different org
20+
FLY_ORG: personal
1721
jobs:
1822
lint:
1923
name: ⬣ ESLint
2024
runs-on: ubuntu-22.04
2125
steps:
2226
- name: ⬇️ Checkout repo
2327
uses: actions/checkout@v4
24-
2528
- name: ⎔ Setup node
2629
uses: actions/setup-node@v4
2730
with:
@@ -45,7 +48,6 @@ jobs:
4548
steps:
4649
- name: ⬇️ Checkout repo
4750
uses: actions/checkout@v4
48-
4951
- name: ⎔ Setup node
5052
uses: actions/setup-node@v4
5153
with:
@@ -72,7 +74,6 @@ jobs:
7274
steps:
7375
- name: ⬇️ Checkout repo
7476
uses: actions/checkout@v4
75-
7677
- name: ⎔ Setup node
7778
uses: actions/setup-node@v4
7879
with:
@@ -97,7 +98,6 @@ jobs:
9798
steps:
9899
- name: ⬇️ Checkout repo
99100
uses: actions/checkout@v4
100-
101101
- name: 🏄 Copy test env vars
102102
run: cp .env.example .env
103103

@@ -146,8 +146,7 @@ jobs:
146146
container:
147147
name: 📦 Prepare Container
148148
runs-on: ubuntu-24.04
149-
# only prepare container on pushes
150-
if: ${{ github.event_name == 'push' }}
149+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
151150
steps:
152151
- name: ⬇️ Checkout repo
153152
uses: actions/checkout@v4
@@ -164,37 +163,80 @@ jobs:
164163
- name: 🎈 Setup Fly
165164
uses: superfly/flyctl-actions/[email protected]
166165

167-
- name: 📦 Build Staging Container
168-
if: ${{ github.ref == 'refs/heads/dev' }}
166+
- name: 📦 Build Production Container
169167
run: |
170168
flyctl deploy \
171169
--build-only \
172170
--push \
173171
--image-label ${{ github.sha }} \
174172
--build-arg COMMIT_SHA=${{ github.sha }} \
175-
--app ${{ steps.app_name.outputs.value }}-staging
176-
env:
177-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
173+
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
174+
--app ${{ steps.app_name.outputs.value }}
178175
179-
- name: 📦 Build Production Container
180-
if: ${{ github.ref == 'refs/heads/main' }}
176+
deploy-staging:
177+
name: 🚁 Deploy staging app for PR
178+
runs-on: ubuntu-24.04
179+
outputs:
180+
url: ${{ steps.deploy.outputs.url }}
181+
environment:
182+
name: staging
183+
url: ${{ steps.deploy.outputs.url }}
184+
steps:
185+
- name: ⬇️ Checkout repo
186+
uses: actions/checkout@v4
187+
with:
188+
fetch-depth: '50'
189+
- name: 👀 Read app name
190+
uses: SebRollen/[email protected]
191+
id: app_name
192+
with:
193+
file: 'fly.toml'
194+
field: 'app'
195+
196+
- name: 🎈 Setup Fly
197+
uses: superfly/flyctl-actions/[email protected]
198+
199+
- name: 🚁️ Deploy PR app to Fly.io
200+
if: ${{ github.event.action != 'closed' && env.FLY_API_TOKEN }}
181201
run: |
202+
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
203+
FLY_REGION=$(flyctl config show | jq -r '.primary_region')
204+
205+
# Create app if it doesn't exist
206+
if ! flyctl status --app "$FLY_APP_NAME"; then
207+
# change org name if needed
208+
flyctl apps create $FLY_APP_NAME --org $FLY_ORG
209+
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 }}
210+
flyctl consul attach --app $FLY_APP_NAME
211+
# Don't log the created tigris secrets!
212+
flyctl storage create --app $FLY_APP_NAME --name epic-stack-$FLY_APP_NAME --yes > /dev/null 2>&1
213+
fi
214+
182215
flyctl deploy \
183-
--build-only \
184-
--push \
216+
--ha=false \ # use only one machine for staging
217+
--regions $FLY_REGION \
218+
--vm-size shared-cpu-1x \
219+
--env APP_ENV=staging \
220+
--env ALLOW_INDEXING=false \
221+
--app $FLY_APP_NAME \
185222
--image-label ${{ github.sha }} \
186223
--build-arg COMMIT_SHA=${{ github.sha }} \
187224
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
188-
--app ${{ steps.app_name.outputs.value }}
189-
env:
190-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
191225
226+
- name: 🧹 Cleanup resources when PR is closed
227+
if: ${{ github.event.action == 'closed' && env.FLY_API_TOKEN }}
228+
run: |
229+
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
230+
flyctl storage destroy epic-stack-$FLY_APP_NAME --yes || true
231+
flyctl apps destroy "$app" -y || true
192232
deploy:
193-
name: 🚀 Deploy
233+
name: 🚀 Deploy production
194234
runs-on: ubuntu-24.04
195235
needs: [lint, typecheck, vitest, playwright, container]
196-
# only deploy on pushes
197-
if: ${{ github.event_name == 'push' }}
236+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
237+
environment:
238+
name: production
239+
url: https://${{ steps.app_name.outputs.value }}.fly.dev
198240
steps:
199241
- name: ⬇️ Checkout repo
200242
uses: actions/checkout@v4
@@ -211,19 +253,7 @@ jobs:
211253
- name: 🎈 Setup Fly
212254
uses: superfly/flyctl-actions/[email protected]
213255

214-
- name: 🚀 Deploy Staging
215-
if: ${{ github.ref == 'refs/heads/dev' }}
216-
run: |
217-
flyctl deploy \
218-
--image "registry.fly.io/${{ steps.app_name.outputs.value }}-staging:${{ github.sha }}" \
219-
--app ${{ steps.app_name.outputs.value }}-staging
220-
env:
221-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
222-
223256
- name: 🚀 Deploy Production
224-
if: ${{ github.ref == 'refs/heads/main' }}
225257
run: |
226258
flyctl deploy \
227259
--image "registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.sha }}"
228-
env:
229-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

docs/database.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,24 @@ migrations.
148148
## Seeding Production
149149

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

153-
This is done in the `migration.sql` file that's included in the template. If you
154-
need to seed the production database, modifying migration files manually is the
155-
recommended approach to ensure it's reproducible.
154+
For staging we create a new database for each PR. To make sure that this
155+
database is already filled with some seed data we manually run the following
156+
command:
157+
158+
```sh
159+
npx prisma db execute --file ./prisma/seed.staging.sql --url $DATABASE_URL
160+
```
161+
162+
If you need to seed the production database, modifying migration files manually
163+
is the recommended approach to ensure it's reproducible.
156164

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

160170
1. Create a script very similar to our `prisma/seed.ts` file which creates all
161171
the data you want to seed.
@@ -300,7 +310,6 @@ You've got a few options:
300310
re-generating the migration after fixing the error.
301311
3. If you do care about the data and don't have a backup, you can follow these
302312
steps:
303-
304313
1. Comment out the
305314
[`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37).
306315

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Per-PR Staging Environments
2+
3+
Date: 2025-12-24
4+
5+
Status: accepted
6+
7+
## Context
8+
9+
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:
10+
11+
- **Staging bottleneck**: Only one PR could be properly tested in the staging environment at a time, making parallel development difficult.
12+
- **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.
13+
- **Serial workflow**: Teams couldn't perform parallel quality assurance, forcing them to coordinate who could use staging at any given time.
14+
- **Extra setup complexity**: During initial deployment, users had to create and configure a separate staging app with its own database, secrets, and resources.
15+
16+
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.
17+
18+
This pattern is common in modern deployment workflows (Vercel, Netlify, Render, etc.) and provides isolated environments for testing changes before they reach production.
19+
20+
## Decision
21+
22+
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:
23+
24+
- Gets its own isolated Fly.io application (e.g., `app-name-pr-123`)
25+
- Automatically provisions all necessary resources (SQLite volume, Tigris object storage, Consul for LiteFS)
26+
- Generates and stores secrets (SESSION_SECRET, HONEYPOT_SECRET)
27+
- Seeds the database with test data for immediate usability
28+
- Provides a direct URL to the deployed app in the GitHub PR interface
29+
- Automatically cleans up all resources when the PR is closed
30+
31+
Staging environment secrets are now managed as GitHub environment secrets and passed to Fly in Github Actions.
32+
33+
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.
34+
35+
## Consequences
36+
37+
**Positive:**
38+
39+
- **Isolated testing**: Each PR has its own complete environment, making it clear which changes caused any issues
40+
- **Simplified onboarding**: New users only need to set up one production app, not both production and staging
41+
- **Better reviews**: Reviewers (including non-technical stakeholders) can click a link to see and interact with changes before merging
42+
- **Automatic cleanup**: Resources are freed when PRs close, reducing infrastructure costs
43+
- **Realistic testing**: Each PR tests the actual deployment process, catching deployment-specific issues early
44+
45+
**Negative:**
46+
47+
- **Increased resource usage during development**: Each open PR consumes Fly.io resources (though they're automatically cleaned up)
48+

0 commit comments

Comments
 (0)