<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;0,700;1,400;1,500;1,700&family=Material+Icons+Outlined&display=swap" >
zerops

Give every pull request its own full stack

Jan S.Jan S.
November 12, 20267 min read

An app, a Python worker, PostgreSQL, Valkey, NATS and object storage. I want all of them in a pull-request environment, because all of them are part of the application. A preview URL gets much more useful when you can upload a file and let the worker process it against that PR's own database, queue and storage.

And I want to delete the whole thing when the PR closes. Stage and production should keep their data and carry on.

This is what the zerops-showcase-deploy example is built around, and it shows one of my favourite things about how Zerops works: one project is the whole environment, and one import YAML describes everything inside it. GitHub Actions can create that environment, deploy application code into it again and again, then delete it as a unit.

The dependencies belong in the preview

Suppose your PR changes how the app hands work to a background worker. You can review the diff, run tests, then open a preview connected to the shared staging queue. Now the new app and the old staging worker are in the same conversation. If you also share the database, the migration you wanted to try has a second audience.

You can build this yourself: a database per branch with its own credentials, a queue, a worker deployment, a bucket, the right addresses passed to the app, and a cleanup job that knows which pieces belong together. People do it with Kubernetes namespaces and a pile of scripts. It works, and then someone owns those scripts forever.

On Zerops, the app and its dependencies already belong to a project. The example puts the same six service types into each environment, so the workflow can work with a project name instead of keeping six separate resource lifecycles in GitHub Actions. That's the part I like. You get to spend the YAML on describing the application.

A small preview can use single-node services while production has different resource settings. You can still exercise the app's path through the worker, database, cache, queue and storage with its own data.

Git decides how long the project lives

The deployment repository is an orchestrator. App and worker source live in separate repositories. For a PR, the workflow looks for its head branch in each repository independently; for a branch push, it uses that branch. A missing branch falls back to that service repository's default branch, and the deployment summary records both selected refs and commit SHAs. Create matching branches in both source repositories when reviewing changes that span the app and worker.

Its event mapping is quite practical:

Git event Zerops project What happens
Push to a feature branch showcase-dev-{slug}-{hash} Create on first push, reuse on later pushes, delete with the branch or on expiry.
Open, update or reopen a PR targeting stage showcase-mr-{number} Create or update the PR environment, delete when the PR closes or on expiry.
Merge a PR into stage showcase-stage Deploy to the persistent stage project.
Push to main or push a v* tag showcase-prod Deploy to the persistent production project.

The mr prefix is the example's name for a pull-request environment. A direct push to stage doesn't deploy stage; that job runs after a PR merge.

An open PR can have both its branch project and its PR project running. Repeated pushes reuse them, including their data, which is useful when a reviewer has prepared something to test. Closing the PR removes its PR project and data; deleting the branch removes the branch project.

Create the stack once, deploy code again

The workflow separates project creation from application deployment. That separation is why stage and production can stay in place.

On the first run, the API helper renders the import YAML, imports the project and waits for its infrastructure services. On subsequent runs it finds the existing project and returns its ID. The import is skipped.

Then it deploys code to the existing app and worker services. The helper packages the source, uploads a new application version, starts build-and-deploy and waits for the result.

Here is the stage sequence from the workflow, with summary output omitted:

bash
source zerops/scripts/zerops-api.sh
export ZEROPS_OWNER_REPOSITORY=zeropsio/zerops-showcase-deploy
export ZEROPS_ENV_KIND=stage ZEROPS_ENV_REF=stage

PROJECT_NAME="showcase-stage"
zerops_render_template zerops/recipes/zerops-import.yml.tpl \
  zerops/recipes/env-stage.env "$PROJECT_NAME"
zerops_ensure_project "$ZEROPS_CLIENT_ID" "$PROJECT_NAME" "" 300

zerops_find_service "$PROJECT_ID" "$APP_SERVICE_NAME"
zerops_deploy_service "$SERVICE_FOUND_ID" ./app ./app/zerops.yaml "$ZEROPS_YAML_SETUP"

zerops_find_service "$PROJECT_ID" "$WORKER_SERVICE_NAME"
zerops_deploy_service "$SERVICE_FOUND_ID" ./worker ./worker/zerops.yml "$ZEROPS_YAML_SETUP"

An application release leaves the existing database, cache, queue and storage services in place. I tested it the boring way: upload an image, redeploy both services, look again. Same four infrastructure IDs, same PostgreSQL row, same bytes in the bucket.

Your migrations still run against real data, and the app deploys before the worker, so their interfaces need to tolerate mixed versions during a release. Give previews synthetic data and their own third-party credentials too. An isolated database is little comfort if the preview sends email through your production account.

One service definition, different sizes

The import template defines the services once. Dev, stage and production value files supply their resource settings; the PR environment uses the stage values.

This excerpt shows the shape:

yaml
project:
  name: ${PROJECT_NAME}
  corePackage: ${CORE_PACKAGE}

services:
  - hostname: db
    type: ${DB_TYPE}
    mode: ${DB_MODE}
    priority: 10
    verticalAutoscaling:
      cpuMode: ${DB_CPU_MODE}
      minRam: ${DB_MIN_RAM}
      minFreeRamGB: ${DB_MIN_FREE_RAM}

The renderer uses GNU envsubst to fill in the values. The rest of the template describes the other services in the same project. Build and run instructions stay with the app and worker source, in their application YAML files.

I prefer having one place where the stack is defined. When the application needs a queue, the queue belongs in development as well. The environment files let you choose different capacity without maintaining three independent copies of the service list.

Say you change the database settings in this template and push to a feature branch that already has a project. The project lookup returns its ID, and the app and worker deploy there; the database keeps its existing settings. A new branch project gets the edited template on import. Apply infrastructure changes to persistent stage and production separately from application releases.

Count running environments, then close them

A PR waiting for review still has a database and a worker. If its branch project is running too, count both.

For an example budget, assume a Lightweight project holds 8 shared CPUs, 6.75 GB RAM, 7 GB disk and 5 GB object storage for its whole lifetime. At the published resource rates, that is $25.85 for 30 days, about $0.86 for a day or $0.29 for eight hours. PR projects use the stage sizes, so run the same sum with your own values file.

Resources are billed by the minute. Builds, backups and egress beyond included allowances affect the bill; a Serious core has its own $10 monthly subscription. The daily spending limit sends a warning, so cleanup still has a job to do.

The example removes PR projects when the PR closes and branch projects when the branch is deleted. Events get missed, so an hourly sweep also removes previews whose PR closed, whose branch is gone or which are older than seven days. It only deletes projects the workflow tagged as its own, and it refuses the stage and prod names outright. Deployment and cleanup share a lock per environment, so they can't race. Keep deployment credentials within trusted workflows; fork PRs don't receive those secrets.

Fork the deployment example and adapt it to your application.

Try Zerops in < 5 minutes without installing or forking anything — deploy one of our curated recipes, which are examples covering the full development lifecycle from local dev through staging to production, with built-in seeding, migrations, backups and scaling.

Your favorite language / framework not covered by our recipes? Deploy the Showcase recipe, or join Zerops Discord and we'll help you out.

"Hello World" Examples Bun Python

Production-ready image processing pipeline showcasing distributed architecture on Zerops — a Bun + React frontend with real-time WebSocket updates and live architecture visualization, a Python worker for async image processing via NATS, backed by PostgreSQL, Valkey, and S3-compatible object storage.

Project core
Lightweight

100 GB egress, 15 hours build hours, 5 GB Backup storage, single container

Free
app:3000
Bun
1 Shared Core
0.5 GB RAM
1 GB Disc
worker
Python
1 Shared Core
0.5 GB RAM
1 GB Disc
db:5432, :6432
PostgreSQL
2 Shared Cores
4 GB RAM
1 GB Disc
redis:6379, :6380
Valkey
1 Shared Core
0.5 GB RAM
1 GB Disc
queue:4222, :8222
NATS
1 Shared Core
0.25 GB RAM
1 GB Disc
storage
Object storage
External    5GB Disc
8 Shared Cores
6.75GB RAM
7GB Disk (SSD)
5GB Object storage
$25.80 per month