Build Agentic Preview Environments on Your Own VPS

Build isolated pull request previews on your VPS with Dokploy, PostgreSQL, dedicated workers, safe credentials, tests, and automatic cleanup.

An agent opening a feature branch is handy. An agent producing a complete, isolated environment that you can inspect before merging is far more useful.

You can build this on your own VPS today. Dokploy can handle the preview application lifecycle, while a small controller manages databases and workers.

Use the pull request as the environment boundary

Most conversations about coding agents dwell on code generation. That part is relatively easy. The harder problem is making generated code observable, testable, and safe enough for a person to review.

For many changes, a branch and a diff do not tell the whole story. Reviewers need a running application with realistic data. They may also need background workers, applied migrations, and test results. They need to see the code behave.

The pull request is a sensible boundary for this environment. It already has an owner, a commit SHA, a review state, and a clear closing event. Infrastructure should follow that lifecycle instead of giving engineers another workflow to babysit.

flowchart TB
  A[Agent starts feature branch] --> B[Agent opens draft pull request]
  B --> C[Dokploy creates preview application]
  B --> D[Controller creates preview database]
  D --> E[Run migrations]
  E --> F[Seed synthetic data]
  C --> G[Start application and worker]
  F --> G
  G --> H[Run browser and workflow tests]
  H --> I[Publish status and preview URL]
  I --> J[Human review]
  J --> K{Merge or close}
  K --> L[Stop worker]
  L --> M[Delete preview resources]

Dokploy can handle the application layer

Dokploy preview deployments already cover much of the application lifecycle. Its documentation describes creating previews for pull requests, assigning unique domains, rebuilding when new commits arrive, and removing previews when pull requests close.

Dokploy also exposes the generated hostname through ${{DOKPLOY_DEPLOY_URL}}. The application can use that value to construct its preview URL without guessing which domain it received.

Concurrency limits and label requirements matter. Agent-generated branches can fill a server surprisingly fast. A label such as deploy_preview gives the team an explicit way to decide which drafts may consume capacity.

Use the existing system for the job it already handles. Building another pull request router, domain manager, and container deployment service would mostly duplicate Dokploy.

Add a small preview environment controller

Deploying the application is only part of an isolated preview. Any substantial change may also need separate database credentials, its own schema and seed data, queue state, and a cleanup policy.

Dokploy does not currently document a native database-branching process that creates a database for each preview, injects its connection string, runs migrations, and deletes it when the pull request closes. A small internal controller can handle that work.

I would keep this controller intentionally narrow. It should coordinate the other systems, record state, and apply policy. It should not grow into a home-built deployment platform.

flowchart LR
  A[GitHub webhook] --> B[Preview controller]
  B --> C[Validate repository and label]
  C --> D[Locate Dokploy preview]
  D --> E[Create database and role]
  E --> F[Run migrations and seed]
  F --> G[Start application and worker]
  G --> H[Run verification]
  H --> I[Comment on pull request]
  A --> J{Pull request closed}
  J --> K[Stop worker]
  K --> L[Delete application]
  L --> M[Drop role and database]

For many teams, a TypeScript service running on the VPS is enough. It can receive GitHub webhooks, check that the repository is approved, find the Dokploy preview through its API, and create a database such as preview_pr_842.

The service can create a restricted PostgreSQL role, apply committed migrations, load deterministic synthetic data, and report the result on the pull request. When the pull request closes, it should stop the worker before deleting credentials and data.

Model the controller as a state machine

A webhook delivery is not a transaction. Events may arrive twice, show up late, or land while another operation is running. The controller needs durable state, and each operation must be safe to repeat.

For each pull request, store the repository and pull request number, along with the commit SHA. Also record the Dokploy deployment identifier, database and role names, provisioning state, creation time, and expiration time.

If an open event is repeated, the controller should find the existing database instead of creating another. Cleanup should succeed even when the preview application has already vanished. If a migration fails, the saved state must be enough to retry the operation or clean up without guesswork.

stateDiagram-v2
  [*] --> Requested
  Requested --> Provisioning
  Provisioning --> Migrating
  Migrating --> Seeding
  Seeding --> Starting
  Starting --> Verifying
  Verifying --> Ready
  Provisioning --> Failed
  Migrating --> Failed
  Seeding --> Failed
  Starting --> Failed
  Verifying --> Failed
  Ready --> Updating
  Updating --> Migrating
  Ready --> Cleaning
  Failed --> Cleaning
  Cleaning --> Deleted
  Deleted --> [*]

Put this state where reviewers can see it: in the pull request. Quiet automation is hard to trust. An engineer should be able to tell whether the application is waiting on its database, whether a migration failed, and which commit is actually running.

Pick a database creation strategy on purpose

The simplest approach is to create an empty database, run migrations, and load a deterministic seed. This is usually the safest default because every preview checks whether the committed migration history can build the application from scratch.

That catches a real class of problems. If a migration chain works only against an old developer database, it is broken even when the application happens to run locally.

CREATE DATABASE preview_pr_842;

pnpm db:migrate
pnpm db:seed:preview

A sanitized PostgreSQL template can shorten provisioning when seeding takes too long. It might contain a production-compatible schema with synthetic tenants, representative documents, and generated users. It must never contain customer data.

CREATE DATABASE preview_pr_842 TEMPLATE preview_template;

PostgreSQL templates have some awkward operational limits. The template cannot have active connections while it is copied. The result is also a physical copy, unlike the copy-on-write branching available from services such as Neon.

Dump and restore is slower, but it can be easier to understand and debug. The controller restores a known artifact, applies the pull request migrations, and records exactly which template version it used.

Another option is to let Neon provide the database branch while Dokploy hosts the application. You still need a controller or GitHub workflow to create the branch, retrieve its connection string, pass that string to the preview, and delete the branch afterward.

Credential injection needs careful design

Creating the database is the easy bit. Safely getting its restricted connection string into the right preview container is where things become fiddly.

The cleanest approach is for the controller to set a preview-specific DATABASE_URL through the Dokploy API, then rebuild the preview. Test this against the exact Dokploy release you run because its documented variable model does not promise a dedicated variable hook for every preview.

A credential broker is another workable option. Each preview receives the broker’s internal address and identifies itself with its deployment identity. The broker checks that identity, then returns credentials only for the matching database.

APP_URL=https://${{DOKPLOY_DEPLOY_URL}}
PREVIEW_DATABASE_BROKER_URL=http://preview_controller.internal

The container can use the returned connection string to run migrations and start the application. PostgreSQL administrator credentials stay inside the controller. They never reach application containers or agent-generated code.

I would not compromise on that boundary for convenience. An agent may propose infrastructure changes, but deploying its branch should not grant it unrestricted access to the database server.

Build the worker from the same commit

Many preview systems stop with the web process. That can produce a convincing but inaccurate environment when a feature depends on queues, indexing, notifications, scheduled tasks, or asynchronous document processing.

Build the preview worker from the same image and commit SHA as the application. Its startup command and resource policy should be the only meaningful differences.

flowchart LR
  I[Image from pull request SHA] --> A[Preview application]
  I --> W[Preview worker]
  A --> D[Pull request database]
  W --> D
  D --> Q[Isolated pg boss schema]
  A --> U[Preview URL]
  W --> S[Captured side effects]

If pg boss keeps queue and schedule state in PostgreSQL, a separate pull request database isolates that state automatically. Production workers cannot see preview jobs, and preview workers cannot see production jobs.

One image running as two services is usually the cleanest setup. You can put both processes in one container, but then health checks, logs, restarts, and resource use become tangled for no real gain.

Long-running workers consume capacity even when they are idle. A second label such as preview_worker can reserve them for changes that need background execution. That keeps ordinary previews lighter without removing worker testing where it matters.

Never copy live queue state

A database clone may contain much more than application records. It can include pending emails, payment tasks, notification jobs, retry records, and schedules that are ready to run.

Starting a preview worker against such a clone could make a review environment send real messages or trigger real work. Honestly, this is the part of the design that makes me most uneasy.

The source template should contain no runtime queue state. If a restore can include queue tables, the controller must clear them and create a clean queue schema before starting any preview worker.

Restore sanitized data
Clear queue jobs and schedules
Apply pull request migrations
Seed preview jobs
Start preview worker

The order matters. Start the worker last. During cleanup, stop it first.

Make preview mode fail closed

A preview should prove that workflows function without causing real-world effects. Each external system needs explicit preview behavior.

Send email to a capture service and keep payments in test mode. Record push notifications instead of delivering them. Leave scrapers and recurring schedules disabled unless the pull request is specifically meant to test them.

APP_ENV=preview
SCHEDULES_ENABLED=false
EMAIL_MODE=capture
PAYMENTS_MODE=test
NOTIFICATIONS_MODE=capture
SCRAPERS_ENABLED=false

If preview configuration is missing, application code should reject the external action. It must never fall back silently to production credentials or endpoints.

Put this policy in shared platform code rather than a checklist that every agent or reviewer must remember. People miss checklist items eventually. A default-deny system does not get tired.

Report readiness on the pull request

The useful output is more than a URL. Reviewers need a small evidence package showing what is running and what the system checked.

The controller should report the preview address, database identity, deployed commit, and migration result. It should also include seed, worker, and browser-test status. Update one persistent comment instead of posting another comment after every commit.

Preview environment ready

Application: https://preview.example.com
Database: preview_pr_842
Commit: a8b31d9

Deployment: Ready
Migrations: Passed
Synthetic seed: Passed
Worker: Ready
Browser tests: Passed

Synthetic data only
Expires when the pull request closes

This also makes partial failures useful. A failed migration still teaches the team something when it is tied to a specific commit, visible to reviewers, and easy to reproduce. Better to find it before merge than after release.

This is where coding agents become practically useful rather than merely impressive. The agent can iterate fast, while the surrounding system keeps every attempt visible and contained.

Treat capacity controls as product behavior

A VPS has limited CPU, memory, storage, and database connections. Once every pull request can create an environment, those limits become a scheduling problem.

Set a cap on active previews and apply CPU and memory limits to each application and worker. Give databases an expiration time. A periodic reconciliation job should delete resources that no longer correspond to an open pull request.

Check repository allowlists and required labels before provisioning anything. Code from public forks should not receive internal network access or credentials. If you must run untrusted code, use a separate node with stricter isolation.

Watch disk use closely. Database copies, image layers, logs, and browser artifacts pile up quietly. Cleanup must remove all of them, not merely the Dokploy deployment.

Start with the smallest version that helps

I would not start with multiple database strategies, dynamic credential brokers, and elaborate worker orchestration. Build the shortest path that gives the team dependable feedback.

The first version can require a deployment label and create an empty PostgreSQL database. It can then run migrations, load synthetic data, deploy the web application, execute a small browser suite, and delete the environment when the pull request closes.

Add workers only for repositories that need them. Introduce template cloning when seed time becomes a genuine problem. Consider Neon when database copy speed or VPS capacity makes another managed dependency worthwhile.

Speed usually comes from removing decisions, not adding options. A small controller with firm defaults will beat a flexible platform that nobody on the team fully understands.

The operating model matters more than the tools

Vercel and Neon offer more of this workflow as a managed integration. Dokploy and PostgreSQL demand more hands-on work, but they give you direct control over where the infrastructure runs.

Neither approach eliminates operational work; it changes who handles which parts. Managed services leave teams dealing with provider limits and integration quirks. On a VPS, the team owns capacity, cleanup, credentials, and recovery.

The real question is not whether every component is self-hosted. It is whether each pull request gets a disposable environment that reviewers can inspect, with a clear owner and tightly limited side effects.

That is what agents need around them. Let them create branches and iterate quickly, but give every attempt its own application, data, worker, tests, and expiration policy. Keep the merge decision with a person, backed by visible evidence instead of faith in generated code.

Resources

Written by Adib Kadir. Product and engineering executive focused on rolling out AI at enterprise scale.

Start a conversation