Skip to main content

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.
  • kubectl access 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:

SecretWhere it comes fromUsed by
VERCEL_TOKENVercel account settings, TokensDeploy to Vercel
VERCEL_ORG_ID, VERCEL_PROJECT_IDorgId and projectId in .vercel/project.jsonDeploy to Vercel
SIGNADOT_ORGYour Signadot org nameSignadot CLI config
SIGNADOT_API_KEYA Signadot API keySignadot CLI config, and the proxy route at runtime
BACKEND_REPOThe backend repository as owner/nameCheckout of the backend repository
GH_PATA GitHub token that can read the backend repositoryCheckout of the backend repository
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGIONAn IAM user with eks:DescribeCluster and Kubernetes access to the clusterAWS login and kubectl setup
AWS_EKS_CLUSTER_NAMEThe EKS cluster name, which must match the cluster's name in Signadotkubectl setup, and the sandbox's cluster
DOCKERHUB_USERNAMEYour Docker Hub usernameThe backend image reference

Backend repository, used by .github/workflows/ci.yml:

SecretWhere it comes fromUsed by
REGISTRYdocker.io, to match the preview workflowbuild-and-push
DOCKERHUB_USERNAMEYour Docker Hub usernamebuild-and-push, deploy-to-eks
DOCKERHUB_TOKENA Docker Hub access token with write accessbuild-and-push
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_EKS_CLUSTER_NAMEThe same values as the frontend repositorydeploy-to-eks
SIGNADOT_CLUSTER_TOKEN (optional)The cluster token from connecting the cluster in Signadotdeploy-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:

.github/workflows/ci.yml (backend)
- 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.

k8s/deployment.yaml
  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.

sandbox.yaml
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
  • name becomes backend-pr-<PR number>: one sandbox per pull request, updated in place on each push.
  • cluster becomes the value of AWS_EKS_CLUSTER_NAME.
  • The image line is replaced with the image the workflow picks (Step 5).
  • defaultRouteGroup creates the backend-api endpoint. Its preview URL, https://backend-api--backend-pr-<N>.preview.signadot.com, becomes NEXT_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:

src/lib/config/api.ts
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:

src/app/api/proxy/[...path]/route.ts
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:

.github/workflows/vercel-preview.yml (frontend)
- 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:

.github/workflows/vercel-preview.yml (frontend)
- 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:

server.js
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​

SymptomWhat to check
Check Signadot Operator failsThe operator's pods are running in the signadot namespace.
Create Signadot Sandbox failsThe 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 URLsandbox.yaml on the backend's main branch defines the backend-api endpoint; run signadot sandbox get backend-pr-<N>.
Preview shows Backend OfflineNEXT_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 URLThe request has no credentials: go through /api/proxy/*, send signadot-api-key, or log in to app.signadot.com.
Preview returns the baseline's responseThe baseline pod has no DevMesh sidecar (1/1, not 2/2). Add the annotation from Step 2 and apply again.
AWS authentication errorsAWS_REGION and AWS_EKS_CLUSTER_NAME, and an IAM user with eks:DescribeCluster and Kubernetes access.
Baseline in ImagePullBackOff after a push to maindeploy-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​