//blog
Self-hosted PR preview environments on your own AWS account
A zero-to-live tutorial: spin up ephemeral GitHub PR preview environments in your own AWS Lightsail account with pullpreview/action@v6 — code and data never leave your infrastructure.
Preview environments are one of the highest-leverage things you can add to a pull request workflow: every PR gets a real, running copy of your app that reviewers, designers, and QA can click through before merge.
Most preview tooling runs that environment on a vendor’s infrastructure and bills you per preview. PullPreview takes the opposite approach: the environment runs in your own AWS Lightsail account. Your code and data never transit a third party, you pay only your raw cloud bill, and you get real SSH access to every running preview.
By the end of this post you’ll have a working setup where labeling a PR spins up a live preview URL on a Lightsail instance you own — and removing the label (or closing the PR) tears it back down.
Why run previews in your own account
- Data residency. The instance, the containers, and any data they touch live in your AWS account, in the region you choose. Nothing is copied to a SaaS backend.
- No per-preview markup. You pay AWS for the Lightsail instance while it exists, and nothing on top. No per-environment metering.
- Real SSH access. A preview isn’t a black box. You can SSH in to read logs, inspect the database, or run a one-off command (more on this below).
- Fork-safe. Previews are gated behind a label you control, so PRs from forks don’t get to spin up infrastructure or read your secrets unless a maintainer opts them in.
Prerequisites
- An AWS account (Lightsail is the default provider).
- A
docker-compose.ymlin your repo that boots the app. PullPreview runsdocker compose upon the instance, so if your app comes up with Compose locally, it’ll come up in a preview. - A GitHub repository with Actions enabled.
- An AWS access key with permission to manage Lightsail resources, stored as repository secrets.
Step 1 — Store your AWS credentials as secrets
PullPreview authenticates to AWS using standard credentials passed through the workflow environment. In your repository, go to Settings → Secrets and variables → Actions and add:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
The key needs permission to create and destroy Lightsail instances, static IPs, and firewall rules. If your organization prefers short-lived credentials over a long-lived access key, you can wire up GitHub’s OIDC integration with AWS instead and drop the static secrets — the action only needs valid AWS credentials in the environment, however they get there.
Step 2 — Add the workflow
Create .github/workflows/pullpreview.yml:
name: PullPreviewon: schedule: - cron: "30 2 * * *" pull_request: types: [labeled, unlabeled, synchronize, closed, reopened]
jobs: deploy: if: github.event_name == 'schedule' || github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview') runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v5 - uses: pullpreview/action@v6 with: compose_files: docker-compose.yml instance_type: small env: AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" AWS_REGION: "us-east-1"A few things worth calling out:
- The
pull_requesttrigger fires onlabeled,unlabeled,synchronize,closed, andreopened. That’s the full lifecycle: a label brings the environment up, new commits redeploy it, and removing the label or closing the PR brings it down. - The
if:condition gates everything on thepullpreviewlabel. No label, no environment — which is what keeps fork PRs from spinning up infrastructure on their own. - The
scheduletrigger is optional but recommended. It runs a daily cleanup pass so no dangling resources are left behind if a teardown is ever missed. AWS_REGIONpicks the region your previews run in. The default provider islightsail, so AWS is assumed unless you setprovider: hetzner.
Step 3 — Label a PR
Open a pull request and add the pullpreview label. The workflow runs, provisions a Lightsail instance, runs docker compose up on it, and reports back in two places:
- A PR comment — a marker-based comment that PullPreview updates in place on every run, showing the current status and the preview URL.
- The job summary — the GitHub Actions run summary surfaces the same details, including the exact
sshcommand to connect to the instance.
The action also exposes outputs you can consume in later steps:
| Output | Meaning |
|---|---|
live | Whether this run produced a live preview deployment |
url | The URL of the application on the preview server |
host | The hostname or IP address of the preview server |
username | The username to use when you SSH into the server |
Want shell access? The username and host outputs (and the job summary) give you the exact connection details — no guessing at the OS user.
Sizing and configuration knobs
The setup above relies on sensible defaults. Here are the inputs you’re most likely to reach for, with their defaults quoted exactly from action.yml:
instance_type— defaultsmall. Bump it up for heavier apps, or drop tonanofor something lightweight.default_port— default80. The port PullPreview uses when building the clickable preview URL.ports— default80/tcp,443/tcp. The ports opened for external access. Port22is always open so SSH keeps working.cidrs— default0.0.0.0/0. Lock previews down to your office or VPN IP range by narrowing this.ttl— defaultinfinite. Set a maximum lifetime like10hor5dand the scheduled cleanup will reap environments that outlive it.admins— default@collaborators/push. The GitHub logins whose SSH keys get installed on the instance. The default installs keys for collaborators with push access; you can also list specific logins (admins: alice,bob).
The lifecycle, end to end
It helps to think of the whole thing as an event model:
- Add the
pullpreviewlabel → the environment comes up. - Push new commits to the PR → the environment redeploys.
- Remove the label, or close/merge the PR → the environment comes down.
- The daily schedule → reaps anything expired or left dangling.
Because environments are ephemeral and tied to PR activity, you’re never paying for idle infrastructure that someone forgot to clean up.
What it costs
While a preview is running, you pay for exactly one Lightsail instance — the bundle price for the instance_type you chose — billed to your own AWS account. There’s no per-preview surcharge layered on top. Combined with ephemeral teardown and ttl-based cleanup, your bill stays tied to actual usage rather than the number of open PRs.
Where to go next
This is the foundation. From here you can:
- Run previews on Hetzner instead of AWS for lower entry pricing and EU-based data residency — set
provider: hetzner. - Add automatic HTTPS with Let’s Encrypt by enabling
proxy_tls, so every preview gets a real certificate. - Give reviewers SSH access to live environments so they can debug against the running app, not just look at it.
Each of those builds on the same workflow you just set up — the environment is yours, running in your account, for the price of the instance.