Preview Deployment Management#
Preview deployments give every GitHub pull request its own isolated Docker service and Traefik-routed URL. Each preview is a child of a parent application (configured with isPreviewDeploymentsActive = true) and lives in the preview_deployments table . Its appName follows the pattern preview-{parentAppName}-{6-char random suffix} .
Key source files:
| File | Role |
|---|---|
pages/api/deploy/github.ts | Webhook entry point — creates/tears down preview deployments on PR events |
packages/server/src/services/preview-deployment.ts | Core CRUD and cleanup logic |
packages/server/src/db/schema/preview-deployments.ts | DB schema |
apps/dokploy/server/api/routers/application.ts | tRPC router handling parent-application deletion and its cleanup cascade |
Creation via GitHub PR Webhook#
The webhook handler at /api/deploy/github processes pull_request events. After verifying the HMAC-SHA-256 signature , it dispatches on action:
opened/synchronize/reopened/labeled— creates or re-uses an existing preview deployment and enqueues a deploy job .closed— callsremovePreviewDeployment()for every preview tied to the PR'spullRequestId.unlabeled— recognized but skipsshouldCreateDeployment; no new build is triggered .
Security gate: Before creating a deployment, the handler checks previewRequireCollaboratorPermissions. If enabled (the default), it calls checkUserRepositoryPermissions for the PR author. Unauthorized authors are blocked and a security comment is posted to the PR .
Preview limits and label filters: If previewLabels is configured, the PR must carry a matching label. If the current preview count exceeds previewLimit, the app is skipped .
createPreviewDeployment() flow :
- Generates a unique
appName(preview-{parent}-{6-char random}). - Resolves a wildcard domain from
application.previewWildcard(defaults to*.sslip.io). Forsslip.io, it embeds the server IP, e.g.preview-myapp-abc123-1-2-3-4.sslip.io. - Posts an "initializing" comment to the GitHub PR via Octokit .
- Inserts the
preview_deploymentsrow, creates the domain record, and callsmanageDomain()to write the Traefik YAML config .
The deploy job uses applicationType: "application-preview" and carries the previewDeploymentId, distinguishing it from regular application deploys .
Cleanup: Preview Deletion#
removePreviewDeployment(previewDeploymentId) runs five cleanup operations sequentially. Each step is wrapped in its own try/catch so a failure in one does not abort the rest :
- Docker service —
removeService(appName, serverId)runsdocker service rm <appName>locally or over SSH. - Deployment logs —
removeDeploymentsByPreviewDeploymentId()deletes associated deployment log records. - Source code —
removeDirectoryCode(appName, serverId)runsrm -rfon the app's code directory underAPPLICATIONS_PATH. - Traefik config —
removeTraefikConfig(appName, serverId)deletes{DYNAMIC_TRAEFIK_PATH}/{appName}.yml. Because Traefik's file provider runs withwatch: true, the router disappears without a Traefik restart. - DB record — deletes the
preview_deploymentsrow .
Orphan risk: The parent
applicationIdforeign key usesonDelete: "cascade", so deleting the parent application removes the DB rows automatically. However, the Docker service and Traefik config file are not deleted by the cascade — they must be cleaned up by explicit code.
Parent Application Deletion and Orphan Cleanup#
When a parent application is deleted via the application.delete tRPC mutation, the handler:
- Deletes the DB row first . This cascades and removes all child
preview_deploymentsrows via the DB FK, but leaves Docker services and Traefik files on disk. - Runs its own cleanup chain for the parent only:
deleteAllMiddlewares,removeDeployments,removeDirectoryCode,removeMonitoringDirectory,removeTraefikConfig,removeService. - Does not call
removePreviewDeployment()for each child preview before deleting the parent.
This means if a parent application is deleted while preview deployments still exist, their Docker services and Traefik {previewAppName}.yml files become orphaned on the host. The DB rows are gone (cascade), so there is no Dokploy-managed way to clean them up after the fact — manual docker service rm and file deletion are required.
To avoid orphans, explicitly delete or close all associated PRs (which triggers the closed webhook path) before deleting the parent application.
Race Condition Considerations#
Two overlapping async operations can collide during preview cleanup:
1. Concurrent webhook close events. If GitHub sends multiple closed webhook calls for the same PR (e.g. retries), each will call removePreviewDeployment() for the same previewDeploymentId. The first call succeeds; the second call hits findPreviewDeploymentById which throws NOT_FOUND , causing the outer TRPCError with BAD_REQUEST to be returned. The webhook handler catches per-preview errors and continues , so duplicate events are safe.
2. Active build during deletion. If a deploy job is still running when the parent app is deleted, the queue is cleared immediately via cleanQueuesByApplication (non-cloud only) before the cleanup chain starts . In cloud mode, the in-flight deployment is not proactively cancelled before resource removal; a concurrent build could write files or start a container that the cleanup then misses.
3. Docker daemon busy. The broader Docker cleanup utilities use dockerSafeExec() , which polls ps aux before executing to avoid race conditions with concurrent Docker operations. This guard applies to bulk cleanup commands (cleanupAll), not to the individual removeService calls in preview deletion.