End-to-End Hot-Reload-Style Previews with Vercel and Signadot
Vercel gives every pull request an instant frontend preview, but that preview usually calls a
shared staging backend, so the backend half of a full-stack change cannot be reviewed until it is
deployed. This tutorial pairs each Vercel preview deployment with a Signadot sandbox: a GitHub
Actions workflow runs the backend in a sandbox on your Kubernetes cluster, builds the Next.js
preview with the sandbox URL as NEXT_PUBLIC_API_URL, and comments both URLs on the pull request.
Every push updates both halves, so each pull request gets a full-stack preview environment and
nothing is deployed to staging or production.
The example is a Next.js frontend and an Express backend in signadot/examples. The same pattern works for any frontend that reads its API URL at build time and any backend that runs as a Kubernetes Deployment.
How it works
Each repository has one GitHub Actions workflow. The backend workflow builds the image and pushes it to Docker Hub. The frontend workflow runs on every pull request event and wires a Vercel preview to a backend sandbox. The diagram shows what happens when a frontend pull request is opened or updated.
The sandbox, named backend-pr-<number>, forks the baseline vercel-signadot-backend Deployment
and runs the image you choose. Everything else in the cluster stays shared, so a preview adds one
pod rather than a copy of the environment. Signadot gives the sandbox a
preview URL such as
https://backend-api--backend-pr-42.preview.signadot.com.
Preview URLs are authenticated: a request needs a signadot-api-key header or a browser session
logged in to app.signadot.com. So the browser does not call the sandbox. It calls the frontend's own
/api/proxy/* route, which adds the API key on the server and forwards the request. Reviewers need
nothing installed, and the key never reaches the client bundle.
Teams that use the
Signadot Chrome extension
can skip the proxy: build with NEXT_PUBLIC_USE_SIGNADOT_PROXY=false, point NEXT_PUBLIC_API_URL
at an existing backend URL such as an ingress, and let the extension add the routing header for the
selected sandbox.
Before you begin
You need:
- An Amazon EKS cluster with the Signadot Operator
installed, connected to Signadot under the same name as the EKS cluster. The preview workflow
uses one secret,
AWS_EKS_CLUSTER_NAME, for both. On another provider, replace the workflow's two AWS steps with that provider's login. kubectlaccess to the cluster. Node.js is optional, for running the backend locally.- A Signadot org name and API key. A key from a service account is the usual choice for CI.
- A Vercel account with a project for the frontend.
- A Docker Hub account. The preview workflow builds image references under
docker.io. For images of your own services, use a private repository and give the cluster pull access to it, for example with an image pull secret.
Check that the operator is running (all pods in Running state) before you start:
kubectl get pods -n signadot
Plan on 45 to 60 minutes.
Step 1: Create the repositories and secrets
The workflows expect the frontend and backend in separate repositories, because the preview workflow checks out the backend repository to read its sandbox spec. Copy the example's two folders into two new repositories:
frontend/becomes the frontend repository, including.github/workflows/vercel-preview.yml.backend/becomes the backend repository, including.github/workflows/ci.yml.
Paths in the rest of this page are relative to each repository's root. Run vercel link in the
frontend repository to connect it to your Vercel project; the generated .vercel/project.json holds
the org and project IDs. Then add these secrets under Settings > Secrets and variables > Actions.
Frontend repository, used by .github/workflows/vercel-preview.yml:
| Secret | Where it comes from | Used by |
|---|---|---|
VERCEL_TOKEN | Vercel account settings, Tokens | Deploy to Vercel |
VERCEL_ORG_ID, VERCEL_PROJECT_ID | orgId and projectId in .vercel/project.json | Deploy to Vercel |
SIGNADOT_ORG | Your Signadot org name | Signadot CLI config |
SIGNADOT_API_KEY | A Signadot API key | Signadot CLI config, and the proxy route at runtime |
BACKEND_REPO | The backend repository as owner/name | Checkout of the backend repository |
GH_PAT | A GitHub token that can read the backend repository | Checkout of the backend repository |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION | An IAM user with eks:DescribeCluster and Kubernetes access to the cluster | AWS login and kubectl setup |
AWS_EKS_CLUSTER_NAME | The EKS cluster name, which must match the cluster's name in Signadot | kubectl setup, and the sandbox's cluster |
DOCKERHUB_USERNAME | Your Docker Hub username | The backend image reference |
Backend repository, used by .github/workflows/ci.yml:
| Secret | Where it comes from | Used by |
|---|---|---|
REGISTRY | docker.io, to match the preview workflow | build-and-push |
DOCKERHUB_USERNAME | Your Docker Hub username | build-and-push, deploy-to-eks |
DOCKERHUB_TOKEN | A Docker Hub access token with write access | build-and-push |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_EKS_CLUSTER_NAME | The same values as the frontend repository | deploy-to-eks |
SIGNADOT_CLUSTER_TOKEN (optional) | The cluster token from connecting the cluster in Signadot | deploy-to-eks, which installs the operator when it is set |
Step 2: Build the backend image and deploy the baseline
A sandbox forks an existing Deployment, so the backend must first run once in the cluster as the
baseline. To check it locally first, run npm install && npm start; then
curl http://localhost:3000/health returns {"status":"healthy",...}.
Push the backend repository's main branch. The build-and-push job tags each image three ways:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=${{ steps.branch.outputs.BRANCH }}-
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
On main this pushes docker.io/<user>/vercel-signadot-backend tagged latest,
main-<short-sha> and sha-<short-sha>. Pull request builds get the branch and SHA tags, never
latest.
Next, make two edits to k8s/deployment.yaml. Replace YOUR_REGISTRY with your Docker Hub path,
and opt the pod into DevMesh routing. A preview URL sends
its requests to the baseline Service with the sandbox's routing key, and the DevMesh sidecar is what
forwards them to the sandbox; without it, previews reach the baseline. If your cluster already
routes sandboxes with Istio or another mesh, skip the annotation and see
request routing.
template:
metadata:
labels:
app: vercel-signadot-backend
annotations:
sidecar.signadot.com/inject: "true" # added
spec:
containers:
- name: backend
# Image will be overridden by Signadot sandbox
image: docker.io/<user>/vercel-signadot-backend:latest # was YOUR_REGISTRY/...
Apply both manifests and check the pod:
kubectl apply -f k8s/deployment.yaml -f k8s/service.yaml
kubectl get pods -n default -l app=vercel-signadot-backend
NAME READY STATUS RESTARTS AGE
vercel-signadot-backend-7c9f8d6b5d-x2k4p 2/2 Running 0 40s
2/2 means the backend container and the DevMesh sidecar are both running.
The backend workflow's second job, deploy-to-eks, also runs on pushes to main: it applies
k8s/deployment.yaml, installs the operator if SIGNADOT_CLUSTER_TOKEN is set, and sets the
baseline image to the commit's full SHA. Backend CI does not push a full-SHA tag, so if that job
leaves the pod in ImagePullBackOff, apply the manifest above again (see
Troubleshooting).
Step 3: Review the sandbox spec
The sandbox spec lives in the backend repository. The preview workflow reads it from the backend's
main branch and fills in the placeholders on every run.
name: backend-pr-PR_NUMBER
spec:
cluster: CLUSTER_NAME
description: Sandbox environment for vercel-signadot-backend
forks:
- forkOf:
kind: Deployment
namespace: default
name: vercel-signadot-backend
customizations:
images:
- image: docker.io/DOCKERHUB_USERNAME/vercel-signadot-backend:SANDBOX_IMAGE_TAG
command: ["node", "server.js"]
env:
- name: PORT
value: "3000"
defaultRouteGroup:
endpoints:
- name: backend-api
target: http://vercel-signadot-backend.default.svc:3000
namebecomesbackend-pr-<PR number>: one sandbox per pull request, updated in place on each push.clusterbecomes the value ofAWS_EKS_CLUSTER_NAME.- The
imageline is replaced with the image the workflow picks (Step 5). defaultRouteGroupcreates thebackend-apiendpoint. Its preview URL,https://backend-api--backend-pr-<N>.preview.signadot.com, becomesNEXT_PUBLIC_API_URL.
Step 4: Route frontend calls through the proxy
The frontend never hard-codes a backend. Every call goes through getApiUrl(), which picks the
target from two build-time variables:
export const API_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
const USE_SIGNADOT_PROXY =
process.env.NEXT_PUBLIC_USE_SIGNADOT_PROXY !== 'false';
export function isSignadotUrl(url: string = API_URL): boolean {
return url.includes('.preview.signadot.com') || url.includes('.sb.signadot.com');
}
export function getApiUrl(endpoint: string): string {
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
if (isSignadotUrl()) {
if (USE_SIGNADOT_PROXY) {
// Proxy route adds Signadot API key header server-side
return `/api/proxy/${cleanEndpoint}`;
} else {
// Chrome extension approach: use existing app URL
// ...
}
}
const baseUrl = API_URL.endsWith('/') ? API_URL.slice(0, -1) : API_URL;
return `${baseUrl}/${cleanEndpoint}`;
}
When NEXT_PUBLIC_API_URL is a Signadot preview URL, getApiUrl('/health') returns
/api/proxy/health. The proxy route handles GET, POST, PUT, DELETE and PATCH, adds the API key and
forwards the request to the sandbox:
const BACKEND_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
const SIGNADOT_API_KEY = process.env.SIGNADOT_API_KEY || '';
async function handleProxyRequest(
request: NextRequest,
params: { path: string[] },
method: string
) {
// ... build fullBackendUrl from BACKEND_API_URL, the path and the query string
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (isSignadotUrl() && SIGNADOT_API_KEY) {
headers['signadot-api-key'] = SIGNADOT_API_KEY;
}
// ...
const backendResponse = await fetch(fullBackendUrl, {
method,
headers,
body,
});
// ... return the backend's status and JSON body
}
The sample page's BackendStatus component calls /health this way every 30 seconds and shows
Backend Online when the response has status: "healthy".
Step 5: Choose the backend image for previews
The frontend's preview workflow decides which backend image the sandbox runs. As shipped, it uses
latest, the image from the backend's main branch, and writes it into sandbox.yaml:
- name: Get Backend Image Reference
id: backend-image
run: |
# ...
IMAGE_TAG="latest"
IMAGE_REF="${{ env.DOCKER_REGISTRY }}/${DOCKERHUB_USERNAME}/${IMAGE_NAME}:${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_REF}" >> $GITHUB_OUTPUT
- name: Prepare Sandbox Configuration
run: |
# ...
IMAGE_REF="${{ steps.backend-image.outputs.IMAGE_REF }}"
CLUSTER_NAME="${{ secrets.AWS_EKS_CLUSTER_NAME }}"
sed -i "s|cluster:.*|cluster: ${CLUSTER_NAME}|g" backend/sandbox.yaml
sed -i "s|image:.*vercel-signadot-backend.*|image: ${IMAGE_REF}|g" backend/sandbox.yaml
With latest the sandbox runs the same code as the baseline, which is enough to prove the wiring.
To preview an unmerged backend change, set IMAGE_TAG to a tag that backend CI pushed for that
branch; Step 7 does this.
The next steps create or update the sandbox, read its endpoint URL, and deploy the preview with it:
- name: Create Signadot Sandbox
id: sandbox
run: |
# ...
SANDBOX_NAME="backend-pr-${PR_NUM}"
sed -i "s|backend-pr-PR_NUMBER|${SANDBOX_NAME}|g" backend/sandbox.yaml
# ...
SANDBOX_OUTPUT=$(signadot sandbox apply -f backend/sandbox.yaml)
# ...
SANDBOX_JSON=$(signadot sandbox get "${SANDBOX_NAME}" -o json 2>/dev/null)
SANDBOX_URL=$(echo "$SANDBOX_JSON" | jq -r ".endpoints[]? | select(.name == \"${ENDPOINT_NAME}\") | .url" | head -n1)
# ...
echo "sandbox-url=$SANDBOX_URL" >> "$GITHUB_OUTPUT"
- name: Deploy to Vercel with Sandbox URL
id: vercel
uses: amondnet/vercel-action@v25
with:
# ...
vercel-args: '--build-env NEXT_PUBLIC_API_URL=${{ steps.sandbox.outputs.sandbox-url }} --env SIGNADOT_API_KEY=${{ secrets.SIGNADOT_API_KEY }} --force'
The Vercel step sets the two variables differently on purpose. --build-env sets
NEXT_PUBLIC_API_URL at build time, because Next.js inlines NEXT_PUBLIC_ variables into the
client bundle. --env sets SIGNADOT_API_KEY as a runtime variable that only server code, such as
the proxy route, can read. A final step posts both URLs as a pull request comment.
Step 6: Open a pull request and test the preview
Make a small change in the frontend, such as the heading in src/app/page.tsx, and open a pull
request against main. The Deploy Full-Stack Preview workflow runs, and when it finishes it
comments on the pull request:
Full-Stack Preview Deployed!
Your preview environment is ready for testing:
- Frontend Preview: https://<project>-<hash>.vercel.app
- Backend Sandbox: https://backend-api--backend-pr-5.preview.signadot.com
The frontend is automatically configured to use the backend sandbox URL.
Open the frontend preview. The status card reads Backend Online, and the DevTools Network
tab shows a request to /api/proxy/health on the Vercel domain that returns the backend's JSON. The
proxy forwarded that request to the sandbox.
To call the sandbox directly, pass your API key:
curl -H "signadot-api-key: $SIGNADOT_API_KEY" \
https://backend-api--backend-pr-<N>.preview.signadot.com/health
Step 7: Preview a backend change
So far the sandbox runs the same image as the baseline. Give it code the baseline does not have. In
the backend repository, create a branch, add a field to the /health response, and open a pull
request:
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
port: config.port,
version: 'pr-preview', // added
});
});
Backend CI builds the branch and pushes it as <branch>-<short-sha> and sha-<short-sha>; copy one
of those tags from Docker Hub. In the frontend pull request, set IMAGE_TAG in the
Get Backend Image Reference step to that tag and push. The preview workflow runs again:
signadot sandbox apply updates backend-pr-<N> in place with the new image, and Vercel builds a
fresh preview.
Each deployment gets a new Vercel URL, so open the frontend preview from the newest comment. The
/api/proxy/health response now includes "version": "pr-preview", while the baseline still
returns the old response. That is the hot-reload-style loop for the whole stack: every push rebuilds
both halves of the preview, and nothing reaches staging.
Clean up
The preview workflow does not delete sandboxes when a pull request closes. Delete one by name:
signadot sandbox delete backend-pr-<N>
To automate this, add a ttl to sandbox.yaml, or add the
labels that let the Signadot GitHub App
delete the sandbox when the pull request closes. Remove the baseline with
kubectl delete -f k8s/deployment.yaml -f k8s/service.yaml.
Going further
- Replace the CLI steps with
signadot/sandbox-action, which names the sandbox after the pull request and adds the labels for automatic deletion. - In a monorepo, build the backend image inside the preview workflow and pass its tag to the
sandbox, so every preview runs that pull request's backend without editing
IMAGE_TAG. - Fork more services into the same sandbox, or combine sandboxes from several pull requests with a route group, as in Multi-PR feature testing.
- Run end-to-end tests against the preview before the comment is posted, as in Playwright on pull requests.
Troubleshooting
| Symptom | What to check |
|---|---|
| Check Signadot Operator fails | The operator's pods are running in the signadot namespace. |
| Create Signadot Sandbox fails | The baseline Deployment exists in default, the image tag exists on Docker Hub, and the cluster's name in Signadot equals AWS_EKS_CLUSTER_NAME. |
Failed to extract sandbox URL | sandbox.yaml on the backend's main branch defines the backend-api endpoint; run signadot sandbox get backend-pr-<N>. |
| Preview shows Backend Offline | NEXT_PUBLIC_API_URL appears in the Vercel build logs, SIGNADOT_API_KEY has no NEXT_PUBLIC_ prefix, and the proxy route file exists. |
| 401 or 403 from the sandbox URL | The request has no credentials: go through /api/proxy/*, send signadot-api-key, or log in to app.signadot.com. |
| Preview returns the baseline's response | The baseline pod has no DevMesh sidecar (1/1, not 2/2). Add the annotation from Step 2 and apply again. |
| AWS authentication errors | AWS_REGION and AWS_EKS_CLUSTER_NAME, and an IAM user with eks:DescribeCluster and Kubernetes access. |
Baseline in ImagePullBackOff after a push to main | deploy-to-eks uses the full commit SHA as the tag, but build-and-push pushes only short-SHA tags and latest. Point the job at one of those. |
Frequently asked questions
How do I preview backend changes without deploying to production?
Run the changed backend in a sandbox instead of deploying it. A Signadot sandbox forks one Kubernetes Deployment from a shared baseline environment, runs the image you choose, and exposes it at an authenticated preview URL such as https://backend-api--backend-pr-42.preview.signadot.com. Point a frontend preview at that URL and reviewers can exercise the change end to end while staging and production stay untouched.
How do I connect a Vercel preview deployment to a backend for the same pull request?
Create the backend sandbox first, then build the Vercel preview with its URL. In this tutorial a GitHub Actions workflow runs signadot sandbox apply, reads the backend-api endpoint URL from signadot sandbox get, and deploys to Vercel with --build-env NEXT_PUBLIC_API_URL set to that URL. The same workflow comments both URLs on the pull request.
Can Vercel previews call a backend that runs in Kubernetes?
Yes. Vercel hosts the frontend, and the backend stays in your Kubernetes cluster, where the Signadot Operator runs a sandbox for each pull request. The sandbox preview URL is reachable from Vercel over HTTPS with a Signadot API key, so a Next.js proxy route adds the key on the server and forwards each call into the cluster. The backend needs no public ingress or load balancer.
Does the browser ever see the Signadot API key?
No. The workflow passes SIGNADOT_API_KEY to Vercel with --env, so it exists only at runtime on the server, and it has no NEXT_PUBLIC_ prefix, so Next.js never inlines it into the client bundle. The browser calls /api/proxy/* on the Vercel domain, and the proxy route adds the signadot-api-key header before forwarding the request to the sandbox.
Why does a new backend URL need a new Vercel build?
Next.js inlines NEXT_PUBLIC_ variables into the client bundle at build time. Changing NEXT_PUBLIC_API_URL in Vercel after a build does not change existing previews, so the workflow passes the sandbox URL with --build-env and forces a fresh deployment on every run. Pushing a commit or re-running the workflow is how a preview picks up a different backend.
How is this different from deploying a full backend environment per pull request?
A sandbox runs only the workloads it forks, here a single Deployment, and shares every other service and datastore in the baseline environment. Each preview adds one pod, not a copy of every service and database. Changes that span several services or pull requests can be previewed together with a route group.
See also
- Set up pull request sandboxes: the general pattern for a sandbox per pull request
- GitHub Actions integration: the Sandbox Action, CLI templates and automatic deletion with the GitHub App
- Preview URLs: how sandbox preview endpoints are authenticated and routed
- Multi-PR feature testing: preview a feature that spans several pull requests and services
- Mobile app previews: point a mobile app at a sandboxed backend with a routing header
- Access sandboxes: the Chrome extension, routing headers and hosted preview URLs
- Preview environments: the complete guide: frontend-only and full-stack previews compared