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
105 changes: 71 additions & 34 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,6 +14,10 @@ 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
Comment on lines +19 to +20
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

The FLY_ORG environment variable is hardcoded to 'personal' at the workflow level. This creates an issue where all PRs from forks or contributors would attempt to deploy to your personal Fly.io organization, which would fail for external contributors. Consider making this configurable through a repository secret or variable, or document that this needs to be changed per repository.

Suggested change
# Change this if you want to deploy to a different org
FLY_ORG: personal
# Fly organization to deploy to; override via repository variable FLY_ORG
FLY_ORG: ${{ vars.FLY_ORG || 'personal' }}

Copilot uses AI. Check for mistakes.
jobs:
lint:
name: ⬣ ESLint
Expand Down Expand Up @@ -146,8 +150,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,20 +167,7 @@ jobs:
- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/setup-flyctl@1.5

- name: 📦 Build Staging Container
if: ${{ github.ref == 'refs/heads/dev' }}
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 }}

- name: 📦 Build Production Container
if: ${{ github.ref == 'refs/heads/main' }}
run: |
flyctl deploy \
--build-only \
Expand All @@ -186,15 +176,18 @@ jobs:
--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 }}

deploy:
name: 🚀 Deploy
deploy-staging:
name: 🚁 Deploy staging app for PR
runs-on: ubuntu-24.04
Copy link

Copilot AI Dec 24, 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 runs independently without waiting for the test jobs (lint, typecheck, vitest, playwright) to pass. This means a staging deployment could occur even if tests are failing. Consider adding these jobs to the needs array to ensure code quality checks pass before deploying to staging.

Suggested change
runs-on: ubuntu-24.04
runs-on: ubuntu-24.04
needs:
- lint
- typecheck
- vitest
- playwright

Copilot uses AI. Check for mistakes.
needs: [lint, typecheck, vitest, playwright, container]
# only deploy on pushes
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'pull_request' }}
outputs:
url: ${{ steps.deploy.outputs.url }}
concurrency:
group: pr-${{ github.event.number }}
environment:
name: staging
url: ${{ steps.deploy.outputs.url }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
Expand All @@ -211,19 +204,63 @@ jobs:
- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/setup-flyctl@1.5

- name: 🚀 Deploy Staging
if: ${{ github.ref == 'refs/heads/dev' }}
- name: 🏗️ Create Fly app and provision resources
if: github.event.action != 'closed'
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 }}
FLY_APP_NAME="${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}"
FLY_REGION=$(flyctl config show | jq -r '.primary_region')
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

The command uses 'flyctl config show' piped to jq to extract the primary_region, but this assumes the current directory context has a fly.toml file. If the checkout step or working directory is different than expected, this could fail. Consider explicitly specifying the config file path or using a more robust method to get the region value, such as reading it directly from the fly.toml file using the same toml-action used elsewhere in the workflow.

Copilot uses AI. Check for mistakes.

# Create app if it doesn't exist
if ! flyctl status --app "$FLY_APP_NAME"; then
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

The 'flyctl status' command is used to check if an app exists, but this command will exit with a non-zero status code if the app doesn't exist, potentially causing the workflow to fail. The conditional check may not work as expected without proper error handling. Consider using 'flyctl apps list' with grep or adding '|| true' to handle the expected failure case gracefully.

Copilot uses AI. Check for mistakes.
# 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)
flyctl volumes create data --app $FLY_APP_NAME --region $FLY_REGION --size 1 --yes
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
Comment on lines +220 to +221
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

The error output from the tigris storage creation is being completely suppressed with '> /dev/null 2>&1'. While the comment explains this is to avoid logging secrets, this also hides legitimate error messages that could help debug provisioning failures. Consider using a more targeted approach that logs errors while filtering sensitive information, or at least check the exit code and provide a generic error message if the command fails.

Suggested change
# 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
# Don't log the created tigris secrets! Capture errors privately and surface a generic message.
if ! flyctl storage create --app "$FLY_APP_NAME" --name "epic-stack-$FLY_APP_NAME" --yes 2>storage-create-error.log; then
echo "Error: failed to create Tigris storage for app '${FLY_APP_NAME}'. See the Fly.io dashboard or rerun 'flyctl storage create' locally for details."
exit 1
fi

Copilot uses AI. Check for mistakes.
fi

- name: 🚁 Deploy PR app to Fly.io
id: deploy
uses: superfly/fly-pr-review-apps@1.5.0
with:
name: ${{ steps.app_name.outputs.value }}-pr-${{ github.event.number }}
secrets: |
APP_ENV=staging
ALLOW_INDEXING=false
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
build_args: |
COMMIT_SHA=${{ github.sha }}
build_secrets: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}

deploy:
name: 🚀 Deploy production
runs-on: ubuntu-24.04
needs: [lint, typecheck, vitest, playwright, container]
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
with:
fetch-depth: '50'

- name: 👀 Read app name
uses: SebRollen/toml-action@v1.2.0
id: app_name
with:
file: 'fly.toml'
field: 'app'

- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/setup-flyctl@1.5

- 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:
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

An extra blank line was removed. While this change doesn't affect functionality, it's not clear why this formatting change was included in a PR focused on deployment changes. Consider whether this whitespace change adds value to the PR or creates unnecessary noise in the diff.

Suggested change
steps:
steps:

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

The decision document is dated 2025-12-24, which is today's date (December 24, 2025). Ensure this date is accurate and intentional. If this document was drafted earlier, the date should reflect when the decision was actually made rather than when the PR was created.

Copilot uses AI. Check for mistakes.

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)

84 changes: 42 additions & 42 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ Here they are!

Prior to your first deployment, you'll need to do a few things:

1. [Install the Github CLI](https://cli.github.com/)

1. Login to GitHub:

```sh
gh auth login
```

1. [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/).

> **Note**: Try `flyctl` instead of `fly` if the commands below won't work.

2. Sign up and log in to Fly:
1. Sign up and log in to Fly:

```sh
fly auth signup
Expand All @@ -24,17 +32,15 @@ Prior to your first deployment, you'll need to do a few things:
> terminal, run `fly auth whoami` and ensure the email matches the Fly
> account signed into the browser.

3. Create two apps on Fly, one for staging and one for production:
1. Create a Fly app for production:

```sh
fly apps create [YOUR_APP_NAME]
fly apps create [YOUR_APP_NAME]-staging
```

> **Note**: Make sure this name matches the `app` set in your `fly.toml`
> file. Otherwise, you will not be able to deploy.
1. Change the app name in fly.toml to name of the app you just created.

4. Initialize Git.
1. Initialize Git.

```sh
git init
Expand All @@ -47,80 +53,74 @@ Prior to your first deployment, you'll need to do a few things:
git remote add origin <ORIGIN_URL>
```

5. Add secrets:
1. Add secrets:

- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user
settings on Fly and create a new
[token](https://web.fly.io/user/personal_access_tokens/new), then add it to
[your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
with the name `FLY_API_TOKEN`.

- Add a `SESSION_SECRET` and `HONEYPOT_SECRET` to your fly app secrets, to do
this you can run the following commands:
- Create a `FLY_API_TOKEN` by running:

```sh
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]-staging
fly tokens org
```

> **Note**: If you don't have openssl installed, you can also use
> [1Password](https://1password.com/password-generator) to generate a random
> secret, just replace `$(openssl rand -hex 32)` with the generated secret.
- Add this token to your GitHub repo:

- Add a `ALLOW_INDEXING` with `false` value to your non-production fly app
secrets, this is to prevent duplicate content from being indexed multiple
times by search engines. To do this you can run the following commands:
```sh
gh secret set FLY_API_TOKEN --body "<token>"
```

- Add a `SESSION_SECRET` and `HONEYPOT_SECRET` to your fly app secrets for
production:

```sh
fly secrets set ALLOW_INDEXING=false --app [YOUR_APP_NAME]-staging
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32)
```
Comment on lines 73 to 75
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

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

The ALLOW_INDEXING secret has been removed from production setup but is still set for staging environments (line 231). Production environments typically want to allow search engine indexing, while staging should not. However, the documentation previously mentioned setting ALLOW_INDEXING=false for non-production environments. Verify that production's default behavior (when ALLOW_INDEXING is not set) is to allow indexing, or explicitly set ALLOW_INDEXING=true for production to make the intent clear.

Copilot uses AI. Check for mistakes.

6. Create production database:
> **Note**: If you don't have openssl installed, you can also use
> [1Password](https://1password.com/password-generator) to generate a random
> secret, just replace `$(openssl rand -hex 32)` with the generated secret.

1. Create production database:

Create a persistent volume for the sqlite database for both your staging and
production environments. Run the following (feel free to change the GB size
based on your needs and the region of your choice
Create a persistent volume for the sqlite database for your production
environment. Run the following (feel free to change the GB size based on your
needs and the region of your choice
(`https://fly.io/docs/reference/regions/`). If you do change the region, make
sure you change the `primary_region` in fly.toml as well):

```sh
fly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]
fly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]-staging
fly volumes create data --region sjc --size 1
```

7. Attach Consul:
1. Attach Consul:

- Consul is a fly-managed service that manages your primary instance for data
replication
([learn more about configuring consul](https://fly.io/docs/litefs/getting-started/#lease-configuration)).

```sh
fly consul attach --app [YOUR_APP_NAME]
fly consul attach --app [YOUR_APP_NAME]-staging
fly consul attach
```

8. Set up Tigris object storage:
1. Set up Tigris object storage:

```sh
fly storage create --app [YOUR_APP_NAME]
fly storage create --app [YOUR_APP_NAME]-staging
fly storage create
```

This will create a Tigris object storage bucket for both your production and
staging environments. The bucket will be used for storing uploaded files and
other objects in your application. This will also automatically create the
This will create a Tigris object storage bucket for your production
environment. The bucket will be used for storing uploaded files and other
objects in your application. This will also automatically create the
necessary environment variables for your app. During local development, this
is completely mocked out so you don't need to worry about it.

9. Commit!
1. Commit!

The Epic Stack comes with a GitHub Action that handles automatically
deploying your app to production and staging environments.

Now that everything is set up you can commit and push your changes to your
repo. Every commit to your `main` branch will trigger a deployment to your
production environment, and every commit to your `dev` branch will trigger a
deployment to your staging environment.
production environment, and every commit to a PR will trigger a deployment to
your staging environment.

---

Expand Down
Loading
Loading