· 6 min read · Nadeera Silva
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.

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.

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/healthStep 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:
| Command | Where | What it does |
|---|---|---|
prisma migrate dev | Your machine only | Creates migrations from schema changes, uses a shadow database, may offer to reset |
prisma migrate deploy | CI and production | Applies pending, committed migrations. No drift checks, no reset |
prisma db push | Prototyping | Syncs 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
- Tests on every push; deploys blocked on failure.
- Build in CI.
- One deploy at a time (
concurrency). - SSH with a deploy-only key and a pinned host key.
prisma migrate deploy, nevermigrate dev, in production.- A health check that touches the database.
- 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
Keep reading
- How to Verify a Supabase JWT in Fastify with JWKSVerify Supabase JWTs in a Fastify API with JWKS and jose, handle the legacy JWT secret, rotate keys safely, and secure Socket.IO connections too.
- RAG with Citations on PostgreSQL and pgvector: How I Built ItHow I built RAG with citations on PostgreSQL and pgvector for Pactroom: schema, project-scoped retrieval, citation prompts and the HNSW filtering trap.
- Building a Next.js Portfolio Website with Firestore and an AI ChatbotHow I built my Next.js portfolio on Firebase: Firestore content with typed fallbacks, SEO metadata, a guarded AI chatbot and a contact form that checks errors.
Comments
Share a question or note about this article.
Loading comments…