Skip to content

· 6 min read · Nadeera Silva

Web & SaaS

GitHub Actions CI/CD for a Solo SaaS: Tests, SSH Deploy and Prisma Migrations

The GitHub Actions pipeline I run for Pactroom: tests, build, SSH deploy to a VPS, prisma migrate deploy and a health check, with a workflow you can adapt.

An isometric conveyor belt carries boxes through a test arch with green lights and a packing station, then a rocket launches toward a server with a heartbeat line above it, illustrating a CI/CD pipeline from tests to deploy and health check

Pactroom is a SaaS product I build and run on my own. With no team to catch mistakes, the deployment pipeline does a lot of the work a team normally would. Every change goes through GitHub Actions: tests, build, deploy to a Linux VM over SSH, Prisma migrations and a health check. This post explains each step, shows a workflow you can adapt, and covers the mistakes the pipeline protects me from.

Why a solo project needs a CI/CD pipeline

When you're the only developer, deploying by hand is tempting. SSH in, pull, restart, done. It works until the day you forget a step, run a migration twice or ship code that doesn't build on the server. A pipeline makes the release process written down and repeatable, so I can ship a small fix late in the day without wondering what I skipped.

The shape of the system

Pactroom runs as two services behind nginx on a Linux virtual machine:

  • A web app built with TanStack Start, which renders pages on the server.
  • An API built with Fastify and Socket.IO, which owns all data access.

nginx handles TLS and routes traffic, and systemd keeps both services running and restarts them if they stop. The database is managed PostgreSQL on Supabase, with Prisma for schema migrations.

Diagram of the pipeline: a push to GitHub runs tests, then the build, then deploys to the VM over SSH, applies Prisma migrations and finishes with a health check
Each step has to pass before the next one runs.

Step 1: tests on every push

The backend has around 175 tests written with Vitest. They run on every push, and nothing deploys if they fail. The tests that pay off most pin down rules the product must never break:

  • Users can't read or change projects they don't belong to. (The JWT verification post lists the auth cases worth testing.)
  • Invalid input is rejected with a clear error.
  • Limits and edge cases behave as intended.

Step 2: build in CI, not on the server

The web app and API are built in GitHub Actions. If the TypeScript build fails, it fails where it's visible, not on the production machine halfway through a deploy.

Step 3: GitHub Actions deploy to a VPS over SSH

Once tests and build pass, the deploy job connects over SSH, updates the application and restarts the services. Because deploys only run from the pipeline, they happen the same way every time.

Here's a simplified workflow in the same shape. Adjust paths and service names to your setup:

name: deploy
on:
  push:
    branches: [main]

concurrency: production   # never run two deploys at once

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 24, cache: npm }
      - run: npm ci
      - run: npm test
      - run: npm run build

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 24, cache: npm }
      - run: npm ci && npm run build
      - name: Set up SSH
        run: |
          install -m 700 -d ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
      - name: Upload and restart
        run: |
          rsync -az --delete dist/ deploy@${{ secrets.DEPLOY_HOST }}:/srv/app/dist/
          rsync -az prisma package.json package-lock.json deploy@${{ secrets.DEPLOY_HOST }}:/srv/app/
          ssh deploy@${{ secrets.DEPLOY_HOST }} '
            cd /srv/app && npm ci --omit=dev &&
            npx prisma migrate deploy &&
            sudo systemctl restart app-api app-web'
      - name: Health check
        run: curl --fail --retry 10 --retry-delay 3 --retry-all-errors https://api.example.com/health

Step 4: Prisma migrate deploy in production

Schema changes are applied with Prisma migrations during the deploy, so the database schema and the code move forward together. Use the right command for each place:

CommandWhereWhat it does
prisma migrate devYour machine onlyCreates migrations from schema changes, uses a shadow database, may offer to reset
prisma migrate deployCI and productionApplies pending, committed migrations. No drift checks, no reset
prisma db pushPrototypingSyncs the schema without migration files

Prisma's docs describe migrate dev as "for use in development environments only" and recommend migrate deploy as part of a CI/CD pipeline. Commit the prisma/migrations folder so CI has something to apply.

Habits that keep migrations safe:

  • Prefer additive changes, like new columns and tables.
  • To remove a column, stop using it in code, deploy, and drop it in a later release.
  • Treat a failed migration as a failed deploy.

Step 5: a health check endpoint

After the services restart, the pipeline calls a health endpoint. If it doesn't answer correctly, the job fails and I know straight away, not from a user. A useful health endpoint:

  • Returns 200 only when the app can do real work, for example after a cheap database query.
  • Responds quickly and needs no authentication.
  • Doesn't expose versions, hostnames or error details publicly.
app.get("/health", async (_req, reply) => {
  try {
    await prisma.$queryRaw`SELECT 1`;
    return { ok: true };
  } catch {
    return reply.code(503).send({ ok: false });
  }
});

Scheduled jobs and supporting scripts

Pactroom sends a weekly digest email to project members. That runs as a scheduled job defined in the repository with everything else, not set up by hand. Alongside the pipeline sit provisioning scripts for a new server and smoke tests for key paths after a deploy, so rebuilding the environment is a documented process rather than a memory exercise.

CI/CD checklist for a solo project

  1. Tests on every push; deploys blocked on failure.
  2. Build in CI.
  3. One deploy at a time (concurrency).
  4. SSH with a deploy-only key and a pinned host key.
  5. prisma migrate deploy, never migrate dev, in production.
  6. A health check that touches the database.
  7. Secrets only in GitHub Actions secrets.

You don't need a complex platform to get most of the benefit. One workflow file covers a lot. There's more on the architecture in the Pactroom case study.

Frequently asked questions

How do I deploy to a VPS with GitHub Actions?

Run tests and the build, then connect over SSH with a deploy-only key stored in GitHub Actions secrets, copy the build, run migrations and restart the services.

What is the difference between prisma migrate dev and migrate deploy?

migrate dev is for development only: it creates migrations and uses a shadow database. migrate deploy applies committed migrations in CI and production without drift checks or resets.

Should I use prisma db push in production?

No. db push is for prototyping and does not use migration files. Production should use migrate deploy.

What should a health check endpoint do?

Return 200 only when the app can do real work, for example after a cheap database query, respond quickly, and not expose internal details.

How do I stop two deploys running at once?

Set a concurrency group on the workflow, such as concurrency: production.

Sources

  1. Using secrets in GitHub Actions (GitHub)
  2. Deploying database changes with Prisma Migrate (Prisma)
  3. Vitest: Getting Started (Vitest)
  4. Prisma CLI reference (migrate dev, migrate deploy) (Prisma)
  5. Prototyping your schema (db push) (Prisma)
  6. Control the concurrency of workflows and jobs (GitHub)
SharePost on XLinkedIn

Comments

Share a question or note about this article.

Loading comments…