Staging and production for a small SaaS with GitHub Actions

Sept. 9, 2026
Use case

A small SaaS needs somewhere to try a change before customers see it, without building a release process that takes more time than the product. On JetDeploy that setup is two Apps from the same repository, each with its own database, and two short GitHub Actions workflows:

  • every push to main deploys staging;
  • every version tag (v1.4.0) deploys the same commit to production, after an approval if you want one;
  • both jobs fail when the deploy fails, not only when the push fails.
push to maintag v1.4.0optional approvalworkflowworkflowacme-stagingApp · test keysacmeApp · live keys, replicasacme-staging-dbPostgreSQLacme-dbPostgreSQLtest herecustomers
The same commit reaches staging on every push and production on a version tag.

1. Two Apps, two databases

Staging Production
App acme-staging acme
Default host acme-staging.jetdeploy.app acme.jetdeploy.app + your domain
Database PostgreSQL acme-staging-db PostgreSQL acme-db
Pre-deploy script same migrations same migrations
Differs by test API keys, sandbox payments, DEBUG off live keys, more replicas

Keep the two environments as similar as the budget allows: same processes, same pre-deploy script, same kind of database. Only environment variables and replicas should differ. Then a migration that passes on staging has already run the exact code that production will run.

Billing is per organization, for a reserved amount of vCPU and memory that every App and service shares, so a staging App carries no charge of its own. It does use part of that plan while it runs, and you can stop it when nobody is testing.

2. Secrets and variables in GitHub

Add two repository secrets under Settings › Secrets and variables › Actions:

Secret Value
JD_GIT_TOKEN Git Access Token
JD_TOKEN API token

The App names and ids are not secret, so they go straight into each workflow. Read an id from the Console or with GET /api/v1/apps?name=acme.

To have a second person approve each release, create a production environment with required reviewers and add environment: production to the production job. Check your GitHub plan first: in private repositories, environments need GitHub Pro, Team or Enterprise, and required reviewers need Enterprise.

The Git Access Token opens every App you can access, in every organization you belong to. Keep it in repository secrets only, and regenerate it from the Git Access Token page if it ever appears in a log.

3. The deploy script

Both workflows call the same script. It pushes the commit, finds the operation that push started, and waits for it:

#!/usr/bin/env bash
# .github/scripts/jetdeploy-deploy.sh
set -euo pipefail

: "${JD_APP:?}" "${JD_APP_ID:?}" "${JD_GIT_TOKEN:?}" "${JD_TOKEN:?}"
API="https://jetdeploy.com/api/v1"
AUTH="Authorization: Bearer $JD_TOKEN"
SHA=$(git rev-parse HEAD)

git -c credential.helper= \
    -c credential.helper='!f() { echo username=git; echo "password=$JD_GIT_TOKEN"; }; f' \
    push "https://jetdeploy.com/git/$JD_APP" "HEAD:refs/heads/main"

for _ in $(seq 1 30); do
  push=$(curl -sSf -H "$AUTH" "$API/apps/$JD_APP_ID" | jq -c .latest_push)
  if [ "$(jq -r .sha <<<"$push")" = "$SHA" ] && [ "$(jq -r .operation <<<"$push")" != null ]; then
    break
  fi
  sleep 2
done
op=$(jq -r .operation <<<"$push")
if [ "$(jq -r .sha <<<"$push")" != "$SHA" ] || [ "$op" = null ]; then
  echo "No deploy started for $SHA; is there a process on $JD_APP?" >&2
  exit 1
fi
echo "Following operation $op"

while :; do
  result=$(curl -sSf -H "$AUTH" "$API/operations/$op?wait_seconds=240")
  [ "$(jq -r .is_finished <<<"$result")" = true ] && break
done

status=$(jq -r .status <<<"$result")
echo "Operation $op finished: $status"
if [ "$status" != SUCCESS ]; then
  jq -r .error_message <<<"$result" >&2
  exit 1
fi

How it works:

  • The credential helper passes the token for this one command. The empty credential.helper= first clears any helper already configured, so the token is never written to disk.
  • latest_push in GET /apps/<id> carries the sha JetDeploy received and the operation it started. Matching the sha makes sure the job follows its own deploy, not an earlier one. Re-running a job without a new commit pushes nothing, so it follows the previous deploy again; to deploy the same commit a second time, call POST /api/v1/apps/<id>/deploy.
  • wait_seconds=240 makes each request return as soon as the operation finishes, or after 240 seconds at most. Polling this way costs a handful of requests per deploy.
  • The exit code is non-zero when the deploy ends in FAILURE or REVOKED, and the job log shows the error_message.

4. The workflows

Staging, on every push to main:

# .github/workflows/deploy-staging.yml
name: Deploy staging
on:
  push:
    branches: [main]
concurrency: deploy-staging
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - run: bash .github/scripts/jetdeploy-deploy.sh
        env:
          JD_APP: acme-staging
          JD_APP_ID: "41"
          JD_GIT_TOKEN: ${{ secrets.JD_GIT_TOKEN }}
          JD_TOKEN: ${{ secrets.JD_TOKEN }}

Production, on every version tag:

# .github/workflows/deploy-production.yml
name: Deploy production
on:
  push:
    tags: ["v*"]
concurrency: deploy-production
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - run: bash .github/scripts/jetdeploy-deploy.sh
        env:
          JD_APP: acme
          JD_APP_ID: "42"
          JD_GIT_TOKEN: ${{ secrets.JD_GIT_TOKEN }}
          JD_TOKEN: ${{ secrets.JD_TOKEN }}
  • fetch-depth: 0 is required: JetDeploy refuses a push from a shallow clone.
  • concurrency keeps runs of the same workflow from overlapping. At most one run waits, and a newer one replaces it, so after three quick tags only the first and the last deploy. On the JetDeploy side, a push that arrives while a deploy runs is not refused: its deploy waits for the running one to finish.
  • A tag checks out the tagged commit, and the script pushes it as main of the production App.

Releasing is then:

git tag v1.4.0 && git push origin v1.4.0

5. When something goes wrong

  • Staging fails: production is untouched. Fix and push to main again.
  • Production fails in the build or the pre-deploy script: the previous version keeps serving. The job is red and the log carries the reason.
  • Production went live and misbehaves: roll back with the API or from your AI agent, then tag a fix. Re-tagging an older commit does not work as a rollback: it is a non-fast-forward push to main of the production App.

Each App builds its own image, so a release costs one build on staging and one on production. Both build the same commit, from the same Dockerfile.

Next step

When production gets its own domain, Custom domain and HTTPS goes through the records and the certificate. For what to do when a release fails, see When a deploy fails.

The deploy script was tested locally against a mock of the API and a local git remote, for a successful and a failed deploy. The workflows follow the GitHub Actions and JetDeploy documentation and were not run on GitHub.

Related Articles

All posts