GitHub Actions E2E Testing: Localhost vs Deployed URL 2026
Run Playwright and Cypress E2E tests in GitHub Actions two ways: spin up your app on localhost in CI, or test a deployed Vercel/Netlify preview URL. Matrix, wait-on, secrets, artifacts.
GitHub Actions E2E Testing: Localhost vs Deployed URL 2026
End-to-end tests answer the question unit tests cannot: does the whole application actually work when a real browser clicks through it? Running those tests in GitHub Actions on every pull request is how you keep that answer green. But there are two fundamentally different targets you can point your E2E suite at in CI, and confusing them is the source of most flaky, slow, or misleading pipelines. You can build and run the app on localhost inside the CI runner and test against http://localhost:3000, or you can let a platform like Vercel or Netlify build a deployed preview URL and test the real, deployed environment. Each has a place; the trick is knowing which to use when and wiring both correctly.
This guide is a complete, copy-pasteable 2026 reference for both approaches with Playwright and Cypress. We cover spinning up your app in the runner and waiting for it to be ready before tests start, testing a deployed preview URL by capturing it from the deployment, the browser matrix, passing secrets and base URLs safely, and uploading traces, videos, and reports as artifacts so failures are debuggable after the runner is gone. Every workflow is real YAML, and the test snippets are runnable TypeScript. If you searched for "github actions e2e testing ci/cd deployed url localhost testing," this is built to be your one-stop answer.
Getting CI E2E right is one of the highest-leverage things a QA-minded engineer can do for a team. For installable QA skills and deeper material, see the skills directory and the blog, including the GitHub Actions testing CI/CD guide and the Playwright CI GitHub Actions complete guide. Let's frame the decision first.
Localhost vs Deployed URL: Which to Use
The two strategies optimize for different things. Localhost in CI builds your app inside the runner and serves it on a local port. It is hermetic — no external dependencies, no network to a deploy platform — so it is fast and reproducible, and it works on every branch including forks. Its limitation is fidelity: you are testing a freshly built bundle in a container, not the actual production-like environment with its CDN, edge functions, and real environment variables.
Deployed preview URL tests the artifact a platform actually built and deployed. Vercel and Netlify create a unique preview URL per pull request; pointing your E2E suite at that URL exercises the real serverless functions, the real edge config, and the exact bundle that would ship. The cost is coupling and timing: your tests now depend on the deploy succeeding first, the preview URL must be captured and passed in, and you inherit any network flakiness to that environment.
| Factor | Localhost in CI | Deployed preview URL |
|---|---|---|
| Fidelity to production | Medium | High (real deploy) |
| Speed | Fast (no deploy wait) | Slower (waits for deploy) |
| Hermetic / reproducible | Yes | No (external dependency) |
| Works on fork PRs | Yes | Often restricted (secrets) |
| Tests edge/serverless runtime | No | Yes |
| Best for | Fast PR feedback, most tests | Pre-merge/pre-prod smoke |
A pragmatic setup uses localhost for the bulk of E2E on every PR (fast, hermetic) and a deployed-URL smoke run before merge or on the deploy event (high fidelity). The rest of this guide shows both.
Spinning Up the App on Localhost in CI
The localhost pattern has three steps: build the app, start it in the background, and wait until it is actually serving before launching tests. That last step is where pipelines break — if tests start before the server is up, the first navigation fails and the whole run looks broken. Use wait-on (or a health-check loop) to gate on readiness.
# .github/workflows/e2e-localhost.yml
name: E2E (localhost)
on:
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Build the app
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start app and wait until ready, then test
run: |
npm run start & # serve in the background
npx wait-on http://localhost:3000 --timeout 60000
npx playwright test
env:
BASE_URL: http://localhost:3000
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
The npm run start & backgrounds the server, and wait-on blocks until http://localhost:3000 responds, with a 60-second ceiling so a broken build fails fast instead of hanging. Playwright reads BASE_URL from the environment, which we wire into the config next.
Many teams prefer to let Playwright manage the server itself via webServer, which removes the manual background process and wait entirely.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry', // capture a trace when a test retries
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
retries: process.env.CI ? 2 : 0,
// Playwright starts the server and waits for the URL automatically:
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
timeout: 60_000,
reuseExistingServer: !process.env.CI,
},
});
With webServer configured, your CI step collapses to just npx playwright test — Playwright builds nothing, but it starts the command, waits for the URL, runs the tests, and tears the server down. This is the cleanest localhost pattern and the one to prefer.
Testing a Deployed Preview URL (Vercel/Netlify)
For the deployed-URL approach, your E2E job must run after the deploy and receive the preview URL. There are two common ways to get that URL: trigger on the platform's deployment_status event (which carries the URL), or read it from the deploy step's output. Here is the deployment_status pattern, which fires automatically when Vercel or Netlify finishes a preview deploy.
# .github/workflows/e2e-deployed.yml
name: E2E (deployed preview)
on:
deployment_status:
jobs:
e2e:
# Only run when the deploy actually succeeded
if: ${{ github.event.deployment_status.state == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run E2E against the preview URL
run: npx playwright test
env:
# The platform puts the live preview URL on the event payload:
BASE_URL: ${{ github.event.deployment_status.target_url }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-deployed
path: playwright-report/
The key line is BASE_URL: ${{ github.event.deployment_status.target_url }} — GitHub populates that with the exact preview URL Vercel/Netlify deployed, and Playwright's config reads it. No build, no server to start; you are testing the real deployed environment.
If you deploy via a CLI step instead, capture the URL into the step output and read it downstream.
- name: Deploy preview and capture URL
id: deploy
run: |
url=$(npx vercel deploy --prebuilt --token "${{ secrets.VERCEL_TOKEN }}")
echo "preview_url=$url" >> "$GITHUB_OUTPUT"
- name: Test the captured URL
run: npx playwright test
env:
BASE_URL: ${{ steps.deploy.outputs.preview_url }}
The echo "preview_url=$url" >> "$GITHUB_OUTPUT" line is the idiomatic way to pass a value between steps; the next step reads it as ${{ steps.deploy.outputs.preview_url }}. Note that fork pull requests usually cannot access deploy secrets, which is exactly why the localhost pattern is the better default for untrusted PRs.
Browser Matrix
To test across Chromium, Firefox, and WebKit, use a job matrix. GitHub runs one job per matrix entry in parallel, so the wall-clock cost is roughly that of a single browser. Pass the browser name into Playwright via the --project flag.
# matrix excerpt
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false # let other browsers finish if one fails
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- run: npx playwright test --project=${{ matrix.browser }}
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: report-${{ matrix.browser }}
path: playwright-report/
fail-fast: false is important for test matrices: without it, a single WebKit failure cancels the in-flight Chromium and Firefox jobs, hiding whether those browsers also have problems. The artifact name is suffixed with ${{ matrix.browser }} so each browser's report uploads to a distinct artifact instead of clobbering the others.
| Matrix concept | YAML | Why |
|---|---|---|
| Define variants | matrix: { browser: [...] } | One parallel job each |
| Keep going on failure | fail-fast: false | See all browser results |
| Use the value | ${{ matrix.browser }} | Parameterize commands |
| Unique artifacts | name: report-${{ matrix.browser }} | Avoid overwrites |
Cypress in GitHub Actions (Both Targets)
Cypress follows the same two-target model. The official cypress-io/github-action bundles install, caching, server startup, and waiting into a single step via its start and wait-on inputs — ideal for the localhost pattern.
# .github/workflows/cypress-localhost.yml
name: Cypress (localhost)
on: pull_request
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- name: Cypress run (starts app, waits, tests)
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm run start # background the app
wait-on: 'http://localhost:3000' # gate on readiness
wait-on-timeout: 60
env:
CYPRESS_BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: cypress-artifacts
path: |
cypress/screenshots
cypress/videos
For the deployed-URL target, drop the start/wait-on inputs and pass the preview URL as the base URL instead.
- name: Cypress against deployed preview
uses: cypress-io/github-action@v6
env:
CYPRESS_BASE_URL: ${{ github.event.deployment_status.target_url }}
Cypress reads CYPRESS_BASE_URL automatically and uses it as the root for every cy.visit('/...'). The action only uploads screenshots and videos on failure here (if: ${{ failure() }}), which keeps green runs cheap. For Cypress-specific CI depth, see the Cypress GitHub Actions CI guide.
Secrets, Environment Variables, and Base URLs
E2E tests usually need configuration: the base URL, test-account credentials, API keys for seeding data. Never hard-code these. Store credentials as encrypted repository or environment secrets and inject them as environment variables in the workflow; reserve plain env values for non-sensitive config like the base URL.
- name: Run E2E with secrets injected
run: npx playwright test
env:
BASE_URL: ${{ vars.STAGING_URL }} # non-secret: a repo variable
E2E_USER: ${{ secrets.E2E_USER }} # secret credential
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} # secret credential
API_TOKEN: ${{ secrets.SEED_API_TOKEN }} # secret for test-data seeding
// tests/login.spec.ts — read injected config, never literals
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login'); // relative to baseURL
await page.getByLabel('Email').fill(process.env.E2E_USER!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/dashboard/);
});
| Config type | Where it lives | How to reference |
|---|---|---|
| Base URL (non-secret) | Repository/Environment variable | ${{ vars.STAGING_URL }} |
| Credentials | Repository/Environment secret | ${{ secrets.E2E_PASSWORD }} |
| Per-environment values | GitHub Environment | environment: + scoped secrets |
The distinction between vars (non-secret, visible) and secrets (encrypted, masked in logs) matters: putting a URL in secrets makes logs harder to debug, while putting a password in vars leaks it. Use each for its purpose.
Artifacts: Traces, Videos, and Reports
When a test fails on a runner you cannot SSH into, artifacts are how you debug it. Always upload Playwright's HTML report and traces (or Cypress's screenshots and videos) on failure, and use if: ${{ !cancelled() }} or if: ${{ failure() }} so uploads happen even when the test step exits non-zero.
- name: Upload Playwright trace + report on failure
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
playwright-report/
test-results/
retention-days: 7
A Playwright trace (enabled by trace: 'on-first-retry' in the config above) is the single most valuable artifact: download it, open it with npx playwright show-trace trace.zip, and you get a frame-by-frame timeline of the failed run — DOM snapshots, network, console, and actions. That turns "it's red on CI but green locally" from a guessing game into a five-minute investigation. For more on traces, see the Playwright trace viewer complete guide.
Caching and Speeding Up CI Runs
A slow E2E pipeline gets ignored, so make it fast. Three caches matter: npm dependencies, the build output, and Playwright's browser binaries. The setup-node action caches npm automatically when you pass cache: npm, but browser binaries are large and worth caching explicitly so you do not re-download Chromium, Firefox, and WebKit on every run.
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm } # caches ~/.npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install browsers (only if cache missed)
if: ${{ steps.pw-cache.outputs.cache-hit != 'true' }}
run: npx playwright install --with-deps
- name: Install OS deps even on cache hit
if: ${{ steps.pw-cache.outputs.cache-hit == 'true' }}
run: npx playwright install-deps
The cache key is derived from package-lock.json, so the cache invalidates automatically whenever your dependency tree (and thus the Playwright version) changes — exactly when you do want a fresh browser download. On a cache hit you still install the OS-level dependencies (install-deps) because those live in the runner image, not the cached directory.
| Cache target | Path | Invalidate on |
|---|---|---|
| npm modules | ~/.npm (via setup-node) | lockfile change |
| Playwright browsers | ~/.cache/ms-playwright | lockfile change |
| Build output | .next/cache etc. | source change |
Beyond caching, the biggest speed lever is parallelism, covered next.
Sharding for Large Suites and Handling Flakiness
When a suite grows past a few hundred tests, even a single browser run gets slow. Playwright's native sharding splits the test set across N machines, each running a slice, with results merged afterward. This is different from the browser matrix — sharding parallelizes the same browser's tests across runners.
# Shard a large suite across 4 parallel runners
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4
env: { BASE_URL: http://localhost:3000 }
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
Each shard uploads a blob report; a final job downloads all four and merges them into one HTML report with npx playwright merge-reports. The result: a 400-test suite that took 12 minutes serially finishes in roughly 3.
Flakiness is the other CI reality. Set retries: 2 in the config (as shown earlier) so a test that fails once gets re-run before being marked failed — combined with trace: 'on-first-retry', you capture a trace of exactly the run that flaked. But retries are a safety net, not a cure: a test that needs retries to pass is signalling a real race condition. Treat a high retry rate as a bug to fix, not a number to raise. The durable fixes are web-first assertions (which auto-wait) and avoiding fixed timeouts, the same disciplines that keep local runs stable. For more, see the Playwright retries and flaky test handling guide.
Frequently Asked Questions
Should I test localhost or a deployed URL in GitHub Actions?
Use both for different purposes. Run the bulk of E2E against localhost in the runner on every pull request, because it is fast, hermetic, and works on fork PRs. Add a deployed-preview-URL smoke run before merge or on the deploy event for higher fidelity, since it exercises the real serverless and edge runtime. Localhost gives speed; the deployed URL gives production accuracy.
How do I wait for my app to be ready before running tests?
Gate the test step on a readiness check. Either use npx wait-on http://localhost:3000 --timeout 60000 after backgrounding the server, or let Playwright manage it with the webServer config block, which starts the command and waits for the URL automatically. Without this gate, tests start before the server is serving and the first navigation fails, making the whole run look broken.
How do I get the Vercel or Netlify preview URL into my tests?
Trigger your E2E job on the deployment_status event and read github.event.deployment_status.target_url, which the platform populates with the preview URL. Alternatively, capture the URL from a deploy CLI step into $GITHUB_OUTPUT and reference it downstream. Pass whichever you obtain as BASE_URL (Playwright) or CYPRESS_BASE_URL (Cypress).
How do I run E2E tests across multiple browsers in CI?
Use a job matrix with strategy.matrix.browser: [chromium, firefox, webkit], set fail-fast: false so one browser's failure does not cancel the others, and pass the value into Playwright with --project=${{ matrix.browser }}. GitHub runs each entry as a parallel job, so testing three browsers costs about the same wall-clock time as testing one.
Why do my deployed-URL tests fail on fork pull requests?
Fork pull requests are restricted from accessing repository secrets and often cannot trigger deploys that need tokens, so a deployed-URL job either has no preview to test or no credentials to seed data. This is a security feature, not a bug. For fork PRs, prefer the localhost pattern, which is fully hermetic and needs no secrets, and reserve deployed-URL runs for trusted branches.
How do I store credentials securely for E2E in GitHub Actions?
Store credentials as encrypted repository or environment secrets and inject them as environment variables in the workflow, referencing them with ${{ secrets.NAME }}. Keep non-sensitive values like the base URL in repository variables (${{ vars.NAME }}) so they stay readable in logs. Never hard-code credentials in tests or YAML; read them from process.env in your test code.
How do I debug a test that fails only in CI?
Upload artifacts and inspect them locally. Enable Playwright tracing with trace: 'on-first-retry', upload playwright-report/ and test-results/ with if: ${{ !cancelled() }}, then download the trace and open it via npx playwright show-trace. The trace gives a frame-by-frame timeline with DOM snapshots, network, and console output, which usually reveals the timing or environment difference between CI and local.
Conclusion
There are two right ways to run E2E in GitHub Actions, and a mature pipeline uses both deliberately. Build and serve your app on localhost for fast, hermetic feedback on every pull request — ideally letting Playwright's webServer start and wait for the app — and run a higher-fidelity smoke pass against the deployed Vercel or Netlify preview URL before merge by reading deployment_status.target_url. Parallelize browsers with a non-fail-fast matrix, inject credentials as secrets and the base URL as a variable, and always upload traces and reports on failure so a red run is a five-minute fix, not a mystery.
Copy the workflows above, swap in your start command and URLs, and you have CI E2E that is both fast and trustworthy. For installable, agent-ready QA skills, see the skills directory, and continue with the GitHub Actions testing CI/CD guide and the Playwright CI GitHub Actions complete guide on the blog.