# Automating Pull-Request Preview Environments: Dynamic Subdomains with Traefik on a Development VPS

A developer opens pull request #104 to update a checkout page. Instead of asking a teammate to pull the branch locally, the CI pipeline builds a container image and deploys it to a temporary staging address:

<https://pr-104.staging.example.com>

The reviewer opens the link, checks the changes, and reports any issues before the pull request is merged. Once the PR closes, the preview environment can be removed.

This is the idea behind automated pull-request preview environments. They give each feature branch an independent testing space without requiring a permanent staging server for every change.

A Linux development VPS can host these environments using Docker Compose, Traefik, wildcard DNS, and GitHub Actions. The challenge is making deployments repeatable while controlling network exposure, resource consumption, deployment credentials, and cleanup.

This guide demonstrates the architecture, configuration files, deployment workflow, and teardown process for a single-VPS implementation.

## 1\. How Dynamic Pull-Request Preview Environments Work

A preview environment is a temporary deployment associated with a specific pull request. Unlike a shared staging server, where multiple unfinished features may compete for the same application instance, each preview can run an independent application container or complete service stack. A typical workflow looks like this:

1. A developer opens or updates a pull request.
2. GitHub Actions builds and tests the application's Docker image.
3. An authorized deployment process starts the image on the development VPS.
4. Traefik discovers the container and creates a route based on Docker labels.
5. The reviewer accesses the assigned preview URL.
6. The environment is removed after the pull request closes.\
   \
   **For example:**
   - Pull request #104: [pr-104.staging.example.com](http://pr-104.staging.example.com)
   - Pull request #105: [pr-105.staging.example.com](http://pr-105.staging.example.com)
   - Pull request #106: [pr-106.staging.example.com](http://pr-106.staging.example.com)

   All three hostnames can resolve to the same VPS.

   Traefik distinguishes the applications using the hostname supplied in each HTTP request.

   This approach avoids manually editing reverse proxy configuration for every pull request. It does not remove the need for deployment authorization, DNS configuration, TLS certificates, or access controls.

## **2\. Prepare the Linux Development VPS**

This example assumes an Ubuntu 24.04 LTS VPS with Docker Engine and the Docker Compose plugin installed.

**You also need:**

- A domain or subdomain you control.
- Access to its DNS records.
- A GitHub repository and container registry.
- An application that can run inside Docker.
- SSH key access to the VPS.
- A defined process for approving preview deployments.

A small VPS may support a few lightweight previews. More demanding applications, multiple databases, and concurrent builds require additional resources.

Do not assume that 4 vCPUs and 8 GB RAM will support a fixed number of previews. Measure container memory, CPU usage, and storage consumption before choosing a server size.

For background on staging isolation and server configuration, \[development VPS sandbox setup guide\]

**Create the Docker Networks**

**After installing Docker, verify the installation:**

docker --version

docker compose version

Create a shared network for Traefik and preview applications:

docker network create traefik-public

The shared network allows Traefik to reach application containers without publishing each application's port directly on the VPS.

Each preview can also use its own private Docker network for databases and background services.\
\
**3\. Configure Wildcard DNS**\
\
Creating a new DNS record for every pull request would make the deployment workflow unnecessarily complicated.

Wildcard DNS allows multiple preview hostnames to resolve to one VPS address.

Create an A record similar to:

Type: A

Name: \*.staging

Value: YOUR_VPS_PUBLIC_IP

If your DNS provider requires a fully qualified hostname, enter:

\*.[staging.example.com](http://staging.example.com)

Replace [example.com](http://example.com) with your own domain.

Check DNS resolution:

dig +short [pr-104.staging.example.com](http://pr-104.staging.example.com)

The result should point to your development VPS.

Wildcard DNS only resolves the hostname. It does not create the application container or configure HTTPS.

You also need a reverse proxy that routes incoming requests to the correct preview service.

## 4\. Deploy Traefik with Restricted Docker API Access\\

Traefik's Docker provider reads container labels to discover services and configure routing.

Directly exposing the Docker socket is risky because the Docker API has extensive control over the host.

A better approach is to use a restricted Docker socket proxy and keep that proxy on a private Docker network.\
\
Create a directory:

sudo mkdir -p /opt/traefik/letsencrypt

sudo chmod 700 /opt/traefik/letsencrypt

Create the ACME storage file:

sudo touch /opt/traefik/letsencrypt/acme.json

sudo chmod 600 /opt/traefik/letsencrypt/acme.json

Create /opt/traefik/compose.yaml:

services:

socket-proxy:

image: tecnativa/docker-socket-proxy:latest

restart: unless-stopped

environment:

CONTAINERS: "1"

EVENTS: "1"

NETWORKS: "1"

POST: "0"\
volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - control read_only: true\
  traefik:

image: traefik:v3

restart: unless-stopped

depends_on:\
\
\- socket-proxy ports: - "80:80" - "443:443" environment:\
      CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?required}

command:\
\
"--providers.docker=true" - "--providers.docker.endpoint=tcp://socket-proxy:2375" - "--providers.docker.exposedbydefault=false" - "--providers.docker.network=traefik-public" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - "[\--certificatesresolvers.letsencrypt.acme.email=admin@example.com](mailto:--certificatesresolvers.letsencrypt.acme.email=admin@example.com)" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true" - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"

volumes: - ./letsencrypt:/letsencrypt networks: - control - traefik-public\
\
networks:

control:

internal: true

traefik-public:

external: true

This example assumes Cloudflare manages your DNS zone. If you use another provider, configure a supported DNS challenge provider and its required credentials.

Use a reviewed, version-pinned Docker socket proxy image when deploying a real environment. The latest tag above is illustrative and should be replaced with an approved version or image digest.\
\
Create a protected /opt/traefik/.env file containing:

CF_DNS_API_TOKEN=YOUR_CLOUDFLARE_API_TOKEN

Restrict its permissions:

sudo chmod 600 /opt/traefik/.env

The DNS token should have only the permissions needed for certificate validation.

Start Traefik:

cd /opt/traefik

docker compose config

docker compose up -d

The proxy example is a starting configuration. Before production-like use, validate its Docker API permissions, software versions, certificate issuance, and network isolation.

Refer to the [**official Traefik Docker provider documentation**](https://doc.traefik.io/traefik/reference/routing-configuration/other-providers/docker/) for supported routing labels and provider settings.\
\
**5\. Create the Per-PR Docker Compose Template**\
\
\
Each pull request needs a distinct application deployment.

A separate Compose project name prevents ordinary container and network naming conflicts between preview environments.

Create /opt/previews/compose.yaml:

services:

app:

image: ${PREVIEW_IMAGE:?required}

restart: unless-stopped

init: true

security_opt:

\- no-new-privileges:true\
\
networks:

\- traefik-public

labels:\
\- "traefik.enable=true"\
\
\- "traefik.http.routers.preview-${PR_ID}.rule=Host`pr-${PR_ID}.`[`staging.example.com\`\](<http://staging.example.com>))"\
\
      - "traefik.http.routers.preview-${PR_ID}.entrypoints=websecure"\
      - "traefik.http.routers.preview-${PR_ID}.tls=true"\
\- "traefik.http.routers.preview-${PR_ID}.tls.certresolver=letsencrypt"\
      - "traefik.http.routers.preview-${PR_ID}.[tls.domains](http://tls.domains)\[0\].main=[staging.example.com](http://staging.example.com)"\
\
      - "traefik.http.routers.preview-${PR_ID}.[tls.domains](http://tls.domains)\[0\].sans=\*.[staging.example.com](http://staging.example.com)"\
      - "[traefik.http.services](http://traefik.http.services).preview-${PR_ID}.loadbalancer.server.port=3000"\
    mem_limit: 1g\
    cpus: 1.0\
networks:\
traefik-public:

external: true

The configuration assumes the application listens on port 3000 inside its container.\
\
It also assumes the image already exists in a registry accessible to the VPS and starts correctly without privileged Docker permissions.

For demonstration, this template contains one application service. A project needing PostgreSQL or Redis should add those services on a private per-preview network, without exposing their ports publicly.

The memory and CPU limits are examples. Adjust them after measuring the actual application.\
\
**Deploy a Specific Pull Request**\
\
Set the pull request number and the image reference:

export PR_ID=104

export PREVIEW_IMAGE="[ghcr.io/organization/app@sha256:YOUR_IMAGE_DIGEST](http://ghcr.io/organization/app@sha256:YOUR_IMAGE_DIGEST)"

Use an actual image digest generated by your container build process.

Check the resolved configuration:

docker compose \\

\-f /opt/previews/compose.yaml \\

\-p "pr-${PR_ID}" config

Start the preview:

docker compose \\

\-f /opt/previews/compose.yaml \\

\-p "pr-${PR_ID}" up -d\
\
The unique project name keeps different preview deployments separate.

For consistent releases, prefer immutable image digests over mutable tags such as latest.

Docker's [**Compose project-name documentation**](https://docs.docker.com/compose/how-tos/project-name/) explains how project names isolate deployment resources.\
\
**6\. Configure HTTPS for Dynamic Preview Subdomains**\
\
Preview applications should use HTTPS, particularly when reviewers access internal features over external networks.

Traefik supports automatic certificate management through Let's Encrypt.

Wildcard certificates require DNS-01 validation. The DNS provider must support the chosen challenge workflow, and Traefik needs appropriate credentials.

The preview labels in the previous section request a certificate for:

[staging.example.com](http://staging.example.com)

and:

\*.[staging.example.com](http://staging.example.com)\
\
This allows multiple preview hostnames to share the applicable wildcard certificate.

Certificate creation is not instantaneous or guaranteed. Incorrect DNS permissions, rate limits, propagation delays, or resolver misconfiguration can prevent issuance.\
\
After deployment, inspect the Traefik logs:

cd /opt/traefik

docker compose logs --tail=100 traefik

Then check the preview address in your browser.

**HTTPS does not make a preview environment private.**\
\
If your application contains unfinished functionality, internal dashboards, or sensitive test data, configure authentication, VPN access, or another appropriate access restriction.

Avoid placing production credentials or unmasked customer data in preview deployments.\
\
**7\. Automate Image Builds with GitHub Actions**\
\
GitHub Actions can validate pull-request changes and build container images.

The safest design separates ordinary pull-request testing from privileged VPS deployment.

A pull request from an external contributor should not automatically receive access to SSH credentials, container registry credentials, or staging infrastructure.

Create .github/workflows/pr-build.yml:

name: PR Build\
\
on: pull_request: types: \[opened, synchronize, reopened\]

permissions: contents: read

jobs: build: runs-on: ubuntu-latest

```plaintext
steps:
  - name: Checkout code
    uses: actions/checkout@v4

  - name: Build application image
    run: |
      docker build \
        -t preview-check:${{ github.event.pull_request.number }} .
```

This workflow checks whether a Docker image can be built from the pull request.

It does not publish the image or deploy it to your VPS.

A full delivery pipeline should also run application tests, perform image security checks, publish an approved image to a registry, and record its immutable digest.

For stronger supply-chain controls, pin GitHub Actions to reviewed commit SHAs.\
\
**Deploy Only Through an Authorized Workflow**\
\
For a trusted repository, create a separate deployment workflow using workflow_dispatch and a protected GitHub Actions environment.

**The workflow can accept:**

- A validated numeric pull-request ID.
- An approved immutable container image digest.
- A deployment approval from an authorized maintainer.

After approval, the deployment job connects to the VPS using restricted credentials and executes the project-specific deployment script.

A minimal deployment workflow can look like this:\
\
name: Deploy Approved Preview

on:

workflow_dispatch:

inputs:

pr_number:

description: "Pull request number"

required: true

type: string

image:

description: "Approved image digest reference"

required: true

type: string

permissions:

contents: read

jobs:

deploy:

environment: staging

runs-on: ubuntu-latest

steps:\
\
name: Validate deployment inputs env: PR_NUMBER: ${{ inputs.pr_number }} IMAGE: ${{ inputs.image }} run: | \[\[ "$PR_NUMBER" =~ ^\[0-9\]+$ \]\] || exit 1 \[\[ "$IMAGE" =~ ^ghcr\.io/[a-zA-Z0-9._/-]+@sha256:[a-f0-9]{64}$ \]\] || exit 1

```plaintext
- name: Deploy approved image
  env:
    PR_NUMBER: ${{ inputs.pr_number }}
    IMAGE: ${{ inputs.image }}
    SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
    STAGING_HOST: ${{ secrets.STAGING_HOST }}
    STAGING_USER: ${{ secrets.STAGING_USER }}
    STAGING_HOST_KEY: ${{ secrets.STAGING_HOST_KEY }}
  run: |
    install -m 700 -d ~/.ssh
    printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
    chmod 600 ~/.ssh/id_ed25519
    printf '%s\n' "$STAGING_HOST_KEY" > ~/.ssh/known_hosts
    chmod 600 ~/.ssh/known_hosts

    ssh -o StrictHostKeyChecking=yes \
      "$STAGING_USER@$STAGING_HOST" \
      /usr/local/bin/deploy-preview \
      "$PR_NUMBER" "$IMAGE"
```

The STAGING_HOST_KEY secret must contain the actual verified SSH known-hosts entry. Obtain and verify the server's host key fingerprint through a trusted channel before configuring it.

The remote deploy-preview script must exist on the VPS, validate its arguments, and use the approved Compose file. Do not allow arbitrary shell commands or arbitrary filesystem paths through workflow inputs.

The staging account also needs permission to deploy containers and read approved images from the registry. Keep those permissions restricted to the deployment process.

This workflow is a reference for an approved deployment stage, not a complete unattended PR-to-production pipeline. Registry publication, reviewer approval policies, and remote deployment credentials must be configured for the actual organization.

GitHub's [**Actions security guidance**](https://docs.github.com/en/actions/reference/security/secure-use) explains workflow permissions, secret handling, and the risks of running untrusted pull-request code.\
\
**8\. Remove Closed Pull-Request Environments Safely**\
\
Temporary previews should not continue consuming resources after their pull requests are closed.

However, automatically deleting every Compose project older than a fixed number of hours is dangerous.

An old preview may still be needed for review. A host-wide Docker prune command may also affect unrelated applications.

A safer cleanup process targets a specific preview after confirming that its pull request is closed.

For example, to stop pull request #104:

docker compose \\

\-f /opt/previews/compose.yaml \\

\-p pr-104 down --remove-orphans

This removes the matching application's containers and Compose-managed networks.

Named volumes are preserved by default.

If the deployment includes a temporary database, remove its data only after confirming that the data is disposable.\
\
**Create a Scoped Cleanup Script**\
\
Create /usr/local/bin/cleanup-preview:

#!/usr/bin/env bash

set -euo pipefail

PR_ID="${1:-}"

if \[\[ ! "$PR_ID" =~ ^\[0-9\]+$ \]\]; then

echo "Usage: cleanup-preview PR_NUMBER"

exit 1

fi

PROJECT="pr-${PR_ID}"

COMPOSE_FILE="/opt/previews/compose.yaml"

echo "Removing preview project: ${PROJECT}"

docker compose \\

\-f "$COMPOSE_FILE" \\

\-p "$PROJECT" \\

down --remove-orphans

echo "Cleanup finished: ${PROJECT}"

Make the script executable:

sudo chmod 750 /usr/local/bin/cleanup-preview\
\
This example removes the specified Compose project without deleting unrelated images or volumes.

For automatic cleanup, a trusted workflow should trigger the script after a pull request closes. That workflow must use the numeric PR identifier from the GitHub event, avoid checking out or executing untrusted PR code, and run with narrowly scoped deployment permissions.

A scheduled reconciliation job can also compare active preview projects with the GitHub API and identify abandoned environments.

Do not run host-wide docker image prune -a or docker volume prune automatically on a shared VPS without reviewing the resources that may be affected.\
\
**Should Preview Environments Expire After 48 or 72 Hours?**\
\
An expiration period can help control resource consumption, but it should be a documented team policy rather than an arbitrary deletion rule.

For example, a team might remove closed PR previews after a configurable retention period. Active PRs should remain available unless the project owner approves their removal.

Track the PR status, project name, image reference, creation time, last deployment time, and cleanup outcome.

This creates an audit trail and makes troubleshooting easier when an environment disappears unexpectedly.\
\
**9\. Monitor VPS Capacity and Preview Health**\
\
Running several previews on one VPS makes resource monitoring essential.

Use:

docker stats

Check storage consumption:

docker system df

Inspect a specific preview:

docker compose \\

\-f /opt/previews/compose.yaml \\

\-p pr-104 ps

Check application logs:

docker compose \\

\-f /opt/previews/compose.yaml \\

\-p pr-104 logs --tail=100

Memory usage depends on the number of active environments and the services inside each one.

A static frontend may use comparatively little memory, while a preview with a database, API, background workers, and test runners may require substantially more.

Start with measured application requirements and leave capacity for the Linux host, Traefik, image pulls, and temporary deployment spikes.

## 10\. Common Preview Environment Problems

Preview URL Returns 404

Check wildcard DNS, the Traefik Host rule, and the Docker provider.

Confirm that the hostname matches the label associated with the correct PR ID.

**Preview Returns 502 Bad Gateway**

Confirm that the application container is running and listening on the configured internal port.

Verify that the container is connected to traefik-public.

**HTTPS Certificate Is Missing**

Inspect the certificate resolver, DNS-01 configuration, DNS credentials, and certificate logs.

Verify that the requested wildcard hostname matches the configured TLS domain.

**Multiple Previews Route to the Same Application**

Check router names, service labels, Compose project names, and container network assignments.

Every preview should have unique identifiers.

**Preview Was Not Removed**

Verify the pull-request status and cleanup workflow.

Check whether the cleanup process used the correct Compose file and project name.

**VPS Runs Out of Memory**

Inspect container memory consumption and reduce unnecessary concurrent environments.

Consider moving build jobs to separate runners or increasing VPS resources if the workload requires it.

## 11\. Security Checklist Before Enabling Automated Previews

\
A preview platform executes code that may not yet be approved for production.

For that reason, preview infrastructure needs its own security controls.

Use separate staging credentials, restrict deployment permissions, and avoid production secrets.

Protect the Docker API, keep database services on internal networks, and require authentication for previews that should not be public.

Do not automatically deploy arbitrary external pull-request code onto a trusted VPS. Such deployments should use appropriately isolated infrastructure or a reviewed approval process.

Record image digests and deployment events so each running preview can be traced to its source revision.

Apply operating-system and container security updates, and verify that automated cleanup affects only the intended preview resources.\
\
**Final Thoughts**\\

Automated PR preview environments make feature reviews easier by giving every approved change a predictable staging address.

Traefik handles hostname-based routing, Docker Compose organizes temporary application services, and GitHub Actions can manage builds and authorized deployments.

The long-term value comes from keeping the system controlled: unique environments, predictable configurations, restricted access, measured VPS capacity, and safe cleanup.

A development VPS can be a practical foundation for this workflow, provided the team treats its deployment credentials and shared Docker infrastructure as security-sensitive systems rather than disposable testing tools.
