Skip to content

Commit 290121a

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

11 files changed

Lines changed: 277 additions & 128 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 93 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,18 @@ 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
25+
if: ${{ github.event.action != 'closed' }}
2126
steps:
2227
- name: ⬇️ Checkout repo
2328
uses: actions/checkout@v4
24-
2529
- name: ⎔ Setup node
2630
uses: actions/setup-node@v4
2731
with:
@@ -42,10 +46,10 @@ jobs:
4246
typecheck:
4347
name: ʦ TypeScript
4448
runs-on: ubuntu-22.04
49+
if: ${{ github.event.action != 'closed' }}
4550
steps:
4651
- name: ⬇️ Checkout repo
4752
uses: actions/checkout@v4
48-
4953
- name: ⎔ Setup node
5054
uses: actions/setup-node@v4
5155
with:
@@ -69,10 +73,10 @@ jobs:
6973
vitest:
7074
name: ⚡ Vitest
7175
runs-on: ubuntu-22.04
76+
if: ${{ github.event.action != 'closed' }}
7277
steps:
7378
- name: ⬇️ Checkout repo
7479
uses: actions/checkout@v4
75-
7680
- name: ⎔ Setup node
7781
uses: actions/setup-node@v4
7882
with:
@@ -93,11 +97,11 @@ jobs:
9397
playwright:
9498
name: 🎭 Playwright
9599
runs-on: ubuntu-22.04
100+
if: ${{ github.event.action != 'closed' }}
96101
timeout-minutes: 60
97102
steps:
98103
- name: ⬇️ Checkout repo
99104
uses: actions/checkout@v4
100-
101105
- name: 🏄 Copy test env vars
102106
run: cp .env.example .env
103107

@@ -146,8 +150,7 @@ jobs:
146150
container:
147151
name: 📦 Prepare Container
148152
runs-on: ubuntu-24.04
149-
# only prepare container on pushes
150-
if: ${{ github.event_name == 'push' }}
153+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
151154
steps:
152155
- name: ⬇️ Checkout repo
153156
uses: actions/checkout@v4
@@ -164,37 +167,104 @@ jobs:
164167
- name: 🎈 Setup Fly
165168
uses: superfly/flyctl-actions/setup-flyctl@1.5
166169

167-
- name: 📦 Build Staging Container
168-
if: ${{ github.ref == 'refs/heads/dev' }}
170+
- name: 📦 Build Production Container
169171
run: |
170172
flyctl deploy \
171173
--build-only \
172174
--push \
173175
--image-label ${{ github.sha }} \
174176
--build-arg COMMIT_SHA=${{ github.sha }} \
175-
--app ${{ steps.app_name.outputs.value }}-staging
176-
env:
177-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
177+
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
178+
--app ${{ steps.app_name.outputs.value }}
178179
179-
- name: 📦 Build Production Container
180-
if: ${{ github.ref == 'refs/heads/main' }}
180+
deploy-staging:
181+
name: 🚁 Deploy staging app for PR
182+
runs-on: ubuntu-24.04
183+
# Only run for PRs from the same repository (skip forks) when PR is not closed
184+
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'closed' }}
185+
outputs:
186+
url: ${{ steps.deploy.outputs.url }}
187+
environment:
188+
name: staging
189+
url: ${{ steps.deploy.outputs.url }}
190+
steps:
191+
- name: ⬇️ Checkout repo
192+
uses: actions/checkout@v4
193+
with:
194+
fetch-depth: '50'
195+
- name: 👀 Read app name
196+
uses: SebRollen/toml-action@v1.2.0
197+
id: app_name
198+
with:
199+
file: 'fly.toml'
200+
field: 'app'
201+
202+
- name: 🎈 Setup Fly
203+
uses: superfly/flyctl-actions/setup-flyctl@1.5
204+
205+
- name: 🚁️ Deploy PR app to Fly.io
206+
id: deploy
207+
if: ${{ env.FLY_API_TOKEN }}
181208
run: |
209+
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
210+
FLY_REGION=$(flyctl config show | jq -r '.primary_region')
211+
212+
# Create app if it doesn't exist
213+
if ! flyctl status --app "$FLY_APP_NAME"; then
214+
# change org name if needed
215+
flyctl apps create $FLY_APP_NAME --org $FLY_ORG
216+
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 }}
217+
flyctl consul attach --app $FLY_APP_NAME
218+
# Don't log the created tigris secrets!
219+
flyctl storage create --app $FLY_APP_NAME --name epic-stack-$FLY_APP_NAME --yes > /dev/null 2>&1
220+
fi
221+
182222
flyctl deploy \
183-
--build-only \
184-
--push \
223+
--ha=false \
224+
--regions $FLY_REGION \
225+
--vm-size shared-cpu-1x \
226+
--env APP_ENV=staging \
227+
--env ALLOW_INDEXING=false \
228+
--app $FLY_APP_NAME \
185229
--image-label ${{ github.sha }} \
186230
--build-arg COMMIT_SHA=${{ github.sha }} \
187231
--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 }}
191232
233+
echo "url=https://$FLY_APP_NAME.fly.dev" >> $GITHUB_OUTPUT
234+
235+
cleanup-staging:
236+
name: 🧹 Cleanup staging app
237+
runs-on: ubuntu-24.04
238+
# Only run when PR is closed
239+
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action == 'closed' }}
240+
steps:
241+
- name: ⬇️ Checkout repo
242+
uses: actions/checkout@v4
243+
244+
- name: 👀 Read app name
245+
uses: SebRollen/toml-action@v1.2.0
246+
id: app_name
247+
with:
248+
file: 'fly.toml'
249+
field: 'app'
250+
251+
- name: 🎈 Setup Fly
252+
uses: superfly/flyctl-actions/setup-flyctl@1.5
253+
254+
- name: 🧹 Cleanup resources
255+
if: ${{ env.FLY_API_TOKEN }}
256+
run: |
257+
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
258+
flyctl storage destroy epic-stack-$FLY_APP_NAME --yes || true
259+
flyctl apps destroy "$FLY_APP_NAME" -y || true
192260
deploy:
193-
name: 🚀 Deploy
261+
name: 🚀 Deploy production
194262
runs-on: ubuntu-24.04
195263
needs: [lint, typecheck, vitest, playwright, container]
196-
# only deploy on pushes
197-
if: ${{ github.event_name == 'push' }}
264+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
265+
environment:
266+
name: production
267+
url: https://${{ steps.app_name.outputs.value }}.fly.dev
198268
steps:
199269
- name: ⬇️ Checkout repo
200270
uses: actions/checkout@v4
@@ -211,19 +281,7 @@ jobs:
211281
- name: 🎈 Setup Fly
212282
uses: superfly/flyctl-actions/setup-flyctl@1.5
213283

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-
223284
- name: 🚀 Deploy Production
224-
if: ${{ github.ref == 'refs/heads/main' }}
225285
run: |
226286
flyctl deploy \
227287
--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)