Skip to content

Commit c2e8cf8

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

File tree

11 files changed

+280
-128
lines changed

11 files changed

+280
-128
lines changed

.github/workflows/deploy.yml

Lines changed: 96 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ on:
33
push:
44
branches:
55
- main
6-
- dev
7-
pull_request: {}
6+
pull_request:
7+
types: [opened, reopened, synchronize]
8+
# Clean up the staging environment when a PR is closed
9+
# Use pull_request_target to also run when the PR has merge conflicts
10+
pull_request_target:
11+
types: [closed]
812

913
concurrency:
1014
group: ${{ github.workflow }}-${{ github.ref }}
@@ -14,14 +18,18 @@ permissions:
1418
actions: write
1519
contents: read
1620

21+
env:
22+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
23+
# Change this if you want to deploy to a different org
24+
FLY_ORG: personal
1725
jobs:
1826
lint:
1927
name: ⬣ ESLint
2028
runs-on: ubuntu-22.04
29+
if: ${{ github.event.action != 'closed' }}
2130
steps:
2231
- name: ⬇️ Checkout repo
2332
uses: actions/checkout@v4
24-
2533
- name: ⎔ Setup node
2634
uses: actions/setup-node@v4
2735
with:
@@ -42,10 +50,10 @@ jobs:
4250
typecheck:
4351
name: ʦ TypeScript
4452
runs-on: ubuntu-22.04
53+
if: ${{ github.event.action != 'closed' }}
4554
steps:
4655
- name: ⬇️ Checkout repo
4756
uses: actions/checkout@v4
48-
4957
- name: ⎔ Setup node
5058
uses: actions/setup-node@v4
5159
with:
@@ -69,10 +77,10 @@ jobs:
6977
vitest:
7078
name: ⚡ Vitest
7179
runs-on: ubuntu-22.04
80+
if: ${{ github.event.action != 'closed' }}
7281
steps:
7382
- name: ⬇️ Checkout repo
7483
uses: actions/checkout@v4
75-
7684
- name: ⎔ Setup node
7785
uses: actions/setup-node@v4
7886
with:
@@ -93,11 +101,11 @@ jobs:
93101
playwright:
94102
name: 🎭 Playwright
95103
runs-on: ubuntu-22.04
104+
if: ${{ github.event.action != 'closed' }}
96105
timeout-minutes: 60
97106
steps:
98107
- name: ⬇️ Checkout repo
99108
uses: actions/checkout@v4
100-
101109
- name: 🏄 Copy test env vars
102110
run: cp .env.example .env
103111

@@ -146,8 +154,7 @@ jobs:
146154
container:
147155
name: 📦 Prepare Container
148156
runs-on: ubuntu-24.04
149-
# only prepare container on pushes
150-
if: ${{ github.event_name == 'push' }}
157+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
151158
steps:
152159
- name: ⬇️ Checkout repo
153160
uses: actions/checkout@v4
@@ -164,37 +171,103 @@ jobs:
164171
- name: 🎈 Setup Fly
165172
uses: superfly/flyctl-actions/[email protected]
166173

167-
- name: 📦 Build Staging Container
168-
if: ${{ github.ref == 'refs/heads/dev' }}
174+
- name: 📦 Build Production Container
169175
run: |
170176
flyctl deploy \
171177
--build-only \
172178
--push \
173179
--image-label ${{ github.sha }} \
174180
--build-arg COMMIT_SHA=${{ github.sha }} \
175-
--app ${{ steps.app_name.outputs.value }}-staging
176-
env:
177-
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
181+
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
182+
--app ${{ steps.app_name.outputs.value }}
178183
179-
- name: 📦 Build Production Container
180-
if: ${{ github.ref == 'refs/heads/main' }}
184+
deploy-staging:
185+
name: 🚁 Deploy staging app for PR
186+
runs-on: ubuntu-24.04
187+
# Only run for PRs from the same repository (skip forks)
188+
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
189+
outputs:
190+
url: ${{ steps.deploy.outputs.url }}
191+
environment:
192+
name: staging
193+
url: ${{ steps.deploy.outputs.url }}
194+
steps:
195+
- name: ⬇️ Checkout repo
196+
uses: actions/checkout@v4
197+
with:
198+
fetch-depth: '50'
199+
- name: 👀 Read app name
200+
uses: SebRollen/[email protected]
201+
id: app_name
202+
with:
203+
file: 'fly.toml'
204+
field: 'app'
205+
206+
- name: 🎈 Setup Fly
207+
uses: superfly/flyctl-actions/[email protected]
208+
209+
# Inspired by https://github.com/superfly/fly-pr-review-apps/blob/main/entrypoint.sh
210+
- name: 🚁️ Deploy PR app to Fly.io
211+
id: deploy
212+
if: ${{ env.FLY_API_TOKEN }}
181213
run: |
214+
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
215+
FLY_REGION=$(flyctl config show | jq -r '.primary_region')
216+
217+
# Create app if it doesn't exist
218+
if ! flyctl status --app "$FLY_APP_NAME"; then
219+
# change org name if needed
220+
flyctl apps create $FLY_APP_NAME --org $FLY_ORG
221+
flyctl secrets --app $FLY_APP_NAME set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32)
222+
flyctl consul attach --app $FLY_APP_NAME
223+
# Don't log the created tigris secrets!
224+
flyctl storage create --app $FLY_APP_NAME --name epic-stack-$FLY_APP_NAME --yes > /dev/null 2>&1
225+
fi
226+
227+
flyctl secrets --app $FLY_APP_NAME set SENTRY_DSN=${{ secrets.SENTRY_DSN }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
228+
182229
flyctl deploy \
183-
--build-only \
184-
--push \
230+
--ha=false \
231+
--regions $FLY_REGION \
232+
--vm-size shared-cpu-1x \
233+
--env APP_ENV=staging \
234+
--env ALLOW_INDEXING=false \
235+
--app $FLY_APP_NAME \
185236
--image-label ${{ github.sha }} \
186237
--build-arg COMMIT_SHA=${{ github.sha }} \
187238
--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 }}
191239
240+
echo "url=https://$FLY_APP_NAME.fly.dev" >> $GITHUB_OUTPUT
241+
242+
cleanup-staging:
243+
name: 🧹 Cleanup staging app
244+
runs-on: ubuntu-24.04
245+
if: ${{ github.event.action == 'closed' }}
246+
steps:
247+
- name: 👀 Read app name
248+
uses: SebRollen/[email protected]
249+
id: app_name
250+
with:
251+
file: 'fly.toml'
252+
field: 'app'
253+
254+
- name: 🎈 Setup Fly
255+
uses: superfly/flyctl-actions/[email protected]
256+
257+
- name: 🧹 Cleanup resources
258+
if: ${{ env.FLY_API_TOKEN }}
259+
run: |
260+
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
261+
flyctl storage destroy epic-stack-$FLY_APP_NAME --yes || true
262+
flyctl apps destroy "$FLY_APP_NAME" -y || true
192263
deploy:
193-
name: 🚀 Deploy
264+
name: 🚀 Deploy production
194265
runs-on: ubuntu-24.04
195266
needs: [lint, typecheck, vitest, playwright, container]
196-
# only deploy on pushes
197-
if: ${{ github.event_name == 'push' }}
267+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
268+
environment:
269+
name: production
270+
url: https://${{ steps.app_name.outputs.value }}.fly.dev
198271
steps:
199272
- name: ⬇️ Checkout repo
200273
uses: actions/checkout@v4
@@ -211,19 +284,7 @@ jobs:
211284
- name: 🎈 Setup Fly
212285
uses: superfly/flyctl-actions/[email protected]
213286

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