Проблема: ревью PR без живого превью – это медленно
Вы открываете diff в GitHub и видите… код. А как приложение реально себя ведёт – не видите. Чтобы проверить PR вживую, нужно переключить ветку, поставить зависимости, запустить проект и прокликать сценарии руками. Это 10-15 минут на каждый PR, и большинство ревьюеров просто пропускают этот шаг.
Результат предсказуем: баги проскакивают в main, QA ловит проблемы уже после мержа, а исправления превращаются в новые PR. Цикл обратной связи растягивается с минут до часов. Локальный туннель и CI/CD могут это исправить: каждый PR получает свой публичный URL с работающим приложением.
Традиционные подходы к preview-окружениям
Preview-окружения – не новая идея. Vercel и Netlify давно умеют разворачивать preview для каждого PR. Но у обоих решений общее ограничение: они работают только с фронтендом.
Vercel / Netlify: только фронтенд
Vercel разворачивает preview для Next.js, Astro и других фреймворков с SSR или статической генерацией. Netlify делает то же для статических сайтов и serverless-функций. Оба сервиса отлично подходят для фронтенд-проектов.
Но если ваше приложение — это Go-бэкенд с PostgreSQL, Python API с Celery и Redis, или монолит на Java — Vercel вам не поможет. Вам нужно preview для полного стека, а не только для фронтенда.
Staging-сервер: один на всех
Классический staging-сервер — это общее окружение, куда деплоятся изменения для тестирования. Проблема в том, что staging один, а PR — много. Два разработчика деплоят одновременно — и чьи-то изменения перезаписывают чужие. Очередь на staging тормозит всю команду.
Kubernetes preview namespaces: мощно, но сложно
Некоторые команды создают отдельный namespace в Kubernetes для каждого PR. Это работает, но требует серьёзной инфраструктуры: Helm-чарты, Ingress-контроллеры, wildcard DNS, автоочистка. Для команды из 5-10 человек это избыточно.
Full-stack preview с туннелями: идея
Подход прост. CI-раннер собирает и запускает приложение, fxTunnel создаёт публичный URL к нему, а бот комментирует PR ссылкой. Ревьюер кликает – и видит работающее приложение.
PR открыт
│
▼
CI-раннер (GitHub Actions)
│
├── 1. Собирает приложение (docker compose up)
├── 2. Запускает fxTunnel → получает публичный URL
├── 3. Комментирует PR ссылкой на preview
├── 4. Ждёт завершения (или запускает E2E-тесты)
│
PR закрыт / смержен
│
└── 5. Туннель автоматически закрывается
Преимущества очевидны: preview работает с любым стеком, не нужна отдельная инфраструктура, а туннель автоматически закрывается, когда CI-джоб завершается.
GitHub Actions: полный workflow
Ниже — готовый workflow для GitHub Actions. Он собирает приложение через Docker Compose, запускает fxTunnel и комментирует PR публичным URL. Вы можете скопировать его и адаптировать под свой проект.
Основной workflow
# .github/workflows/preview.yml
name: PR Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start application
run: |
docker compose -f docker-compose.preview.yml up -d --build
# Ждём, пока приложение станет доступным
for i in $(seq 1 30); do
curl -sf http://localhost:8080/health && break
echo "Waiting for app to start... ($i/30)"
sleep 2
done
- name: Install fxTunnel
run: curl -fsSL https://fxtun.dev/install.sh | bash
- name: Start tunnel and get URL
id: tunnel
run: |
# Запускаем туннель в фоне и перехватываем URL
fxtunnel http 8080 --log-file /tmp/tunnel.log &
TUNNEL_PID=$!
echo "tunnel_pid=$TUNNEL_PID" >> "$GITHUB_OUTPUT"
# Ждём, пока URL появится в логах
for i in $(seq 1 15); do
TUNNEL_URL=$(grep -oP 'https://[a-z0-9-]+\.fxtun\.dev' /tmp/tunnel.log || true)
if [ -n "$TUNNEL_URL" ]; then
echo "preview_url=$TUNNEL_URL" >> "$GITHUB_OUTPUT"
echo "Preview URL: $TUNNEL_URL"
break
fi
sleep 1
done
if [ -z "$TUNNEL_URL" ]; then
echo "Failed to get tunnel URL"
exit 1
fi
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
const previewUrl = '${{ steps.tunnel.outputs.preview_url }}';
const body = `## Preview Environment
| | |
|---|---|
| **URL** | ${previewUrl} |
| **Commit** | \`${context.sha.substring(0, 7)}\` |
| **Status** | Active |
This preview will be available while the CI job is running.`;
// Удаляем старый комментарий, если есть
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.body.includes('## Preview Environment')
);
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
- name: Run E2E tests against preview
run: |
npx playwright test --base-url=${{ steps.tunnel.outputs.preview_url }}
- name: Keep preview alive for manual testing
if: success()
run: |
echo "Preview is live at: ${{ steps.tunnel.outputs.preview_url }}"
echo "Keeping alive for 20 minutes for manual review..."
sleep 1200
- name: Cleanup
if: always()
run: |
kill ${{ steps.tunnel.outputs.tunnel_pid }} || true
docker compose -f docker-compose.preview.yml down
Docker Compose для preview
# docker-compose.preview.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/preview
- REDIS_URL=redis://redis:6379
- APP_ENV=preview
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: preview
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 2s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
Подробнее об использовании Docker с туннелями — в статье Docker + туннель.
GitLab CI: краткий пример
Аналогичный workflow для GitLab CI. Принцип тот же: запуск приложения, создание туннеля, комментарий к merge request.
# .gitlab-ci.yml
preview:
stage: test
image: docker:24
services:
- docker:24-dind
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- docker compose -f docker-compose.preview.yml up -d --build
- curl -fsSL https://fxtun.dev/install.sh | bash
- fxtunnel http 8080 --log-file /tmp/tunnel.log &
- sleep 5
- TUNNEL_URL=$(grep -oP 'https://[a-z0-9-]+\.fxtun\.dev' /tmp/tunnel.log)
- |
curl --request POST \
--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
--header "Content-Type: application/json" \
--data "{\"body\": \"Preview: ${TUNNEL_URL}\"}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"
- echo "Preview live at ${TUNNEL_URL}"
- sleep 1200
after_script:
- docker compose -f docker-compose.preview.yml down
Что даёт tunnel preview команде
Что меняется, когда к каждому PR прикладывается живая ссылка? На удивление многое.
Тестирование вебхуков прямо из PR
Если ваше приложение принимает вебхуки от Stripe, GitHub или Telegram, preview-окружение позволяет настроить тестовый вебхук на URL из PR и проверить интеграцию до мержа. QA-инженер может протригерить платёж в тестовом режиме Stripe и увидеть, как приложение его обрабатывает.
Демонстрация для QA и дизайнеров
Ссылка в комментарии к PR позволяет любому члену команды открыть preview в браузере — без клонирования репозитория, установки зависимостей и запуска проекта локально. Дизайнер проверяет UI, продакт-менеджер — бизнес-логику, QA — edge cases.
E2E-тесты против реального URL
Публичный HTTPS-URL от fxTunnel можно передать в Playwright, Cypress или Selenium. Тесты выполняются против приложения, работающего в условиях, приближённых к продакшену: реальный HTTP, TLS, DNS-резолвинг. Это надёжнее, чем тесты против localhost.
Тестирование мобильных приложений
Если к бэкенду подключается мобильное приложение, QA может вписать URL из PR в конфигурацию тестовой сборки и проверить интеграцию на реальном устройстве.
Сравнение стоимости: tunnel preview vs альтернативы
| Подход | Стоимость | Полный стек | Изоляция PR | Настройка |
|---|---|---|---|---|
| fxTunnel (бесплатно) | $0 | Да | Да | 10 минут |
| fxTunnel (платный) | от $5/мес | Да | Да | 10 минут |
| Vercel preview | $0-20/мес | Только фронтенд | Да | 5 минут |
| Netlify preview | $0-19/мес | Только фронтенд | Да | 5 минут |
| Staging-сервер | $20-100/мес | Да | Нет (один на всех) | Часы |
| K8s per-PR namespaces | $50-200/мес | Да | Да | Дни |
Для full-stack проектов fxTunnel даёт изолированное preview для каждого PR с поддержкой любого стека и минимальной стоимостью. Vercel и Netlify остаются отличным выбором для чисто фронтенд-проектов. Сравнение инструментов – в статье ngrok vs Cloudflare vs fxTunnel.
Автоочистка: закрытие туннеля при мерже или закрытии PR
Туннель, запущенный в CI-джобе, автоматически закрывается при завершении джоба. Но если вы используете sleep для поддержания preview активным, нужен механизм отмены при мерже или закрытии PR.
GitHub Actions решает это через concurrency и отдельный workflow на закрытие.
Workflow для отмены при закрытии PR
# .github/workflows/preview-cleanup.yml
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cancel preview workflow
uses: actions/github-script@v7
with:
script: |
// Находим активные workflow runs для этого PR
const runs = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'preview.yml',
status: 'in_progress',
});
for (const run of runs.data.workflow_runs) {
// Проверяем, что run относится к нашему PR
if (run.pull_requests.some(pr => pr.number === context.issue.number)) {
await github.rest.actions.cancelWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
});
console.log(`Cancelled workflow run ${run.id}`);
}
}
- name: Update PR comment
uses: actions/github-script@v7
with:
script: |
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.body.includes('## Preview Environment')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: `## Preview Environment\n\n**Status:** Closed (PR ${context.payload.action})`,
});
}
Ключевой момент — секция concurrency в основном workflow. Параметр cancel-in-progress: true гарантирует, что при новом коммите в PR старый preview-джоб отменяется, а новый запускается с обновлённым кодом. При закрытии PR отдельный workflow отменяет активный preview.
Советы по настройке
Кэширование Docker-слоёв
Сборка Docker-образов при каждом push может быть медленной. Используйте кэш:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: ${{ runner.os }}-buildx-
Health check перед открытием туннеля
Не запускайте туннель, пока приложение не готово. Используйте health check endpoint:
- name: Wait for application
run: |
for i in $(seq 1 30); do
if curl -sf http://localhost:8080/health; then
echo "Application is ready"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "Application failed to start"
exit 1
Ограничение времени жизни preview
Устанавливайте разумный timeout-minutes для джоба и sleep для ожидания. 20-30 минут обычно достаточно для ручного тестирования. Для автоматических E2E-тестов preview может жить только во время прогона тестов.
FAQ
Что такое preview environment для pull request?
По сути, это одноразовый деплой, который автоматически поднимается при открытии PR. Ревьюер получает публичный URL и может кликнуть, чтобы увидеть работающее приложение. fxTunnel создаёт туннель прямо на CI-раннере, так что отдельный сервер не нужен.
Чем tunnel preview отличается от Vercel preview deployments?
Vercel и Netlify заточены под фронтенд – статику и SSR. Preview через туннель работает с чем угодно: Go-бэкенды, Python API, монолиты на Java, базы данных, очереди. Всё, что запускается на CI-раннере, доступно через публичный URL.
Безопасно ли открывать CI-раннер через туннель?
Для preview с тестовыми данными – да. fxTunnel шифрует трафик через TLS. Главное – использовать тестовые базы и API-ключи, не подключать продакшен-секреты. Туннель закроется автоматически, когда CI-джоб завершится.
Можно ли запускать E2E-тесты против tunnel preview URL?
Конечно. Туннель даёт публичный HTTPS-URL, который можно передать в Playwright, Cypress или любой другой фреймворк. Тесты работают против приложения за настоящим HTTP, TLS и DNS – гораздо ближе к продакшену, чем localhost.
Сколько стоит tunnel preview по сравнению со staging-сервером?
fxTunnel бесплатен для базового использования, а платный план от $5/мес обходится значительно дешевле одного staging-сервера ($20-100/мес). Главный выигрыш – изоляция: каждый PR получает собственное окружение, а не общий staging, где разработчики мешают друг другу.