Skip to content
Docs

How to test a container image in Vercel Sandbox before deploying

Validate a container image before deploying by booting it as a custom Sandbox image from Vercel Container Registry (VCR) or running Docker inside a Vercel Sandbox.

Ben SabicContent Engineer

Container images that build cleanly can still fail at runtime.

The server doesn't start, the port never binds, or a migration errors against the image's real filesystem. Vercel Sandbox lets you catch these failures before deploying by running your checks inside an isolated Firecracker microVM, so nothing touches your host machine or production systems.

There are two ways to test an image in a sandbox. You can boot it as a custom Sandbox image from Vercel Container Registry (VCR), or install Docker inside a sandbox and run the image exactly as Docker would. This guide helps you pick the right path, run pass/fail checks against your image, and wire the validation into CI or an agent workflow.

Copy link to headingOverview

In this guide, you'll learn how to:

  • Choose between booting an image as a custom Sandbox image and running Docker inside a sandbox
  • Run install, start, and HTTP smoke checks against the image
  • Verify the container's network behavior with sandbox network policies
  • Automate the validation in CI or an agent workflow
  • Read the results: what a passing sandbox run does and doesn't guarantee

Copy link to headingPrerequisites

Before you begin, make sure you have:

  • A Vercel account and the Vercel CLI installed
  • A linked project with an OIDC token available locally. Run vercel link, then vercel env pull to download VERCEL_OIDC_TOKEN. In external CI systems, use an access token instead.
  • The @vercel/sandbox SDK installed: npm i @vercel/sandbox
  • A container image for testing.

Copy link to headingChoose your validation path

Vercel Sandbox works with container images in two ways, and each validates something different:

Boot the image as the sandboxRun Docker inside a sandbox
MechanismSandbox.create() with a custom image pulled from VCRInstall Docker in a default sandbox, start the daemon with sudo, then docker run the image
What it validatesThe image's filesystem, toolchain, and dependencies, plus any command you run with runCommand()The image exactly as Docker executes it, including ENTRYPOINT and CMD
Key caveatSandbox does not run Docker ENTRYPOINT or CMD; you start processes yourselfContainers don't inherit the sandbox's proxy CA certificate; daemon startup needs sudo
PrerequisitesImage pushed to VCR with a linux/amd64 manifestAny image source reachable from the sandbox
SpeedFast: boots from a precompiled snapshotSlower first run (Docker install plus image pull); persistent sandboxes carry both over between sessions
Best forRepeated smoke tests against a known image, and agent toolchainsEnd-to-end validation, testing the start command itself, and images not yet in VCR

The decision rule: if you need to validate the container's own start behavior (ENTRYPOINT, CMD, and signal handling), use Path B. If you need a fast, repeatable environment built from the image's contents, use Path A.

Copy link to headingBoot the image as a custom Sandbox image (Path A)

Copy link to heading1. Push the image to VCR

Sandbox pulls custom images from VCR, a project-scoped registry hosted at vcr.vercel.com. VCR only serves an image to Sandbox once it has prepared an optimized linux/amd64 build, so build for that platform and use zstd compression:

docker buildx build \
--platform linux/amd64 \
--output "type=image,name=vcr.vercel.com/team-slug/project-slug/my-repository:latest,push=true,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true" \
.

To authenticate locally before pushing, run vercel vcr login, which mints a short-lived, project-scoped OIDC credential valid for 12 hours. After the push, check the repository details page in your project dashboard for the readiness state.

For repository setup and other authentication options, see the Vercel Container Registry documentation.

Copy link to heading2. Create the sandbox from the image

Pass the image reference to Sandbox.create(), along with the port:

import { Sandbox } from '@vercel/sandbox';
const sandbox = await Sandbox.create({
image: 'my-repository:latest',
ports: [3000],
timeout: 10 * 60 * 1000, // 10 minutes
env: { DATABASE_URL: 'your_database_url_here' },
});

The image parameter accepts a repository name, tag, digest, or fully qualified VCR URL. The env parameter sets default environment variables for every command run in the sandbox, which is how you supply configuration the image expects.

Vercel Sandbox does not run Docker ENTRYPOINT or CMD for custom images. The sandbox boots with your image's filesystem in place, but you start processes yourself with sandbox.runCommand(). If the Dockerfile defines WORKDIR, commands start in that directory.

Copy link to heading3. Run your checks with runCommand()

Verify the toolchain and dependencies the image claims to ship, then start the server explicitly. Use detached: true for the server process:

// Check the toolchain baked into the image
const version = await sandbox.runCommand('node', ['--version']);
console.log(await version.stdout());
// Start the server yourself, since CMD did not run
const server = await sandbox.runCommand({
cmd: 'node',
args: ['server.js'],
detached: true,
});

Any nonzero exitCode on a blocking command is a failed check. For the detached server, stream server.logs() if you need to watch for a readiness line.

Copy link to heading4. Smoke-test over the exposed port

sandbox.domain(port) returns a public URL for any port you declared in ports. Send a request and assert on the response:

const url = sandbox.domain(3000);
const response = await fetch(url);
console.log(response.status); // 200
if (!response.ok) {
throw new Error(`Smoke test failed with status ${response.status}`);
}
await sandbox.stop();

A 200 response confirms the image's dependencies and your start command produce a working server. If the request hangs, the server likely never started.

Copy link to headingRun the image with Docker inside a sandbox (Path B)

Copy link to heading1. Create a sandbox and install Docker

Each sandbox is a Firecracker microVM with its own kernel, running an Ubuntu-based managed image by default (legacy runtimes run Amazon Linux 2023) with sudo access, which is what lets you start the Docker daemon inside it.

Install Docker, start the daemon detached, and wait for it to report ready:

import { Sandbox } from '@vercel/sandbox';
const sandbox = await Sandbox.create({
name: 'image-validation',
ports: [3000],
timeout: 15 * 60 * 1000, // 15 minutes
});
await sandbox.runCommand({
sudo: true,

Sandboxes are persistent by default, so the Docker installation and any pulled images carry over between sessions through filesystem snapshots. Running processes don't survive a stop, though, so restart dockerd on each resume.

The onResume hook in Sandbox.getOrCreate() handles this for you (by default it fires when the session actually resumes on your first SDK call; pass resume: true to resume immediately).

Copy link to heading2. Authenticate and pull the image

Pull the image under test from VCR or another registry. For VCR, pass your OIDC token into the sandbox and log in with the oidc username:

await sandbox.runCommand({
cmd: 'sh',
args: [
'-lc',
'printf "%s" "$VERCEL_OIDC_TOKEN" | sudo docker login vcr.vercel.com --username oidc --password-stdin',
],
env: { VERCEL_OIDC_TOKEN: process.env.VERCEL_OIDC_TOKEN! },
});
await sandbox.runCommand({
cmd: 'sh',
args: ['-lc', 'sudo docker pull vcr.vercel.com/team-slug/project-slug/my-repository:latest'],
});

VCR implements the standard Docker Registry HTTP API v2, so docker login works from inside a sandbox the same way it does locally, with no environment-specific setup.

The OIDC token expires after 12 hours, so run vercel env pull again if you see authentication errors. In external CI systems where VERCEL_OIDC_TOKEN is unavailable, use a Vercel access token instead, with the team ID that owns the project as the Docker username. Never write either token into scripts or logs; pass it through env as shown.

Copy link to heading3. Run the container as Docker would

This is the step Path A can't cover: Docker executes the image's real ENTRYPOINT and CMD. Map the container's port to the sandbox port you exposed:

await sandbox.runCommand({
cmd: 'sh',
args: [
'-lc',
'sudo docker run -d --name under-test -p 3000:3000 vcr.vercel.com/team-slug/project-slug/my-repository:latest',
],
});

Copy link to heading4. Exercise it

Check the container is running, inspect its logs, and hit it over the exposed port:

// Confirm the container stayed up
const ps = await sandbox.runCommand({
cmd: 'sh',
args: ['-lc', 'sudo docker ps --filter name=under-test --format "{{.Status}}"'],
});
console.log(await ps.stdout()); // Up 4 seconds
// Inspect startup logs
const logs = await sandbox.runCommand({
cmd: 'sh',
args: ['-lc', 'sudo docker logs under-test'],
});
console.log(await logs.stdout());
// HTTP smoke test through the sandbox's public URL
const response = await fetch(sandbox.domain(3000));
console.log(response.status); // 200

An empty result from docker ps means the container exited; run sudo docker logs under-test to see why, and check the exit code with sudo docker inspect under-test --format "{{.State.ExitCode}}".

If you suspect memory pressure, also inspect the sandbox’s Memory Usage chart against its configured limit and 85% warning line. This measures the sandbox, not an individual container.

Copy link to heading5. Optional: validate with backing services

Docker in a sandbox is also useful for running containerized services like Redis or Postgres as test dependencies alongside the image under test:

await sandbox.runCommand({
cmd: 'sh',
args: ['-lc', 'sudo docker run --rm -d --name redis redis:alpine'],
});
await sandbox.runCommand({
cmd: 'sh',
args: ['-lc', 'sudo docker exec redis redis-cli PING'],
});

This lets migrations, seeds, and integration checks run against real services without provisioning anything outside the microVM.

Copy link to headingLock down the network (optional, both paths)

Sandbox egress traffic passes through a firewall you control. The networkPolicy parameter defaults to allow-all, but you can pass deny-all or an allow list of domains to confirm the image makes only the outbound calls you expect:

const sandbox = await Sandbox.create({
image: 'my-repository:latest',
ports: [3000],
networkPolicy: { allow: ['api.example.com'] },
});

Domain entries without a wildcard are exact matches only. To allow all subdomains, use a leading wildcard such as *.example.com.

Domain allow lists match traffic by SNI, so they apply to TLS traffic only. If the image makes plain-HTTP outbound calls, those are blocked under a domain allow list even when the domain is listed; use subnets.allow to permit non-TLS destinations.

You can also change the policy at runtime with sandbox.update({ networkPolicy }), for example to allow the image pull, then switch to deny-all before starting the container. If your smoke test passes under a restrictive policy, the code paths you exercised made no unexpected outbound calls. Paths the test didn't reach, such as deferred jobs or retries, can still have outbound dependencies.

One behavior to watch in Path B: a container has its own trust store, so it can't see the sandbox's proxy CA certificate, and HTTPS requests from inside the container fail TLS verification when the firewall terminates them.

Copy link to headingAutomate it

Structure the validation as a single script that exits nonzero on failure, so CI can gate a deploy on it.

This example follows the create, check, assert, and stop pattern with Path A:

validate-image.ts
import { Sandbox } from '@vercel/sandbox';
async function main() {
const sandbox = await Sandbox.create({
image: process.env.IMAGE_REF!, // pin by digest in CI
ports: [3000],
timeout: 10 * 60 * 1000,
});
try {
await sandbox.runCommand({
cmd: 'node',
args: ['server.js'],
detached: true,
});
// Poll until the server answers or the deadline passes
const deadline = Date.now() + 60_000;
let ok = false;
while (Date.now() < deadline) {
try {
const res = await fetch(sandbox.domain(3000));
if (res.ok) {
ok = true;
break;
}
} catch {}
await new Promise((r) => setTimeout(r, 2000));
}
if (!ok) throw new Error('Image failed validation: no 200 within 60s');
console.log('Image passed validation');
} finally {
await sandbox.stop();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

Run it in CI with VERCEL_OIDC_TOKEN (or a Vercel access token) and IMAGE_REF set. For one-off manual runs, the Sandbox CLI offers sandbox create with --publish-port and --network-policy flags. The same script works as a tool for AI agents that build images and need to verify them before promotion.

Copy link to headingWhat a passing run does and doesn't validate

Treat a green sandbox run as one ring of validation, not production parity:

A passing run confirmsA passing run does not confirm
The image's filesystem, toolchain, and dependencies workFluid compute lifecycle behavior: $PORT resolution and the 30-second SIGTERM grace period on scale-down
The start command boots a working server (Path B tests the real ENTRYPOINT and CMD)Production request routing and CDN behavior
The server answers HTTP requests with expected status and bodyPerformance under Active CPU pricing and concurrent load
The image's outbound network profile matches your policyBehavior of environment variables and integrations only present in production

For the next ring of validation, deploy the image to a preview deployment and test it on the real Functions runtime before promoting to production.

Copy link to headingTroubleshooting

Copy link to headingSandbox.create() returns image_not_ready

VCR prepares an optimized linux/amd64 snapshot after each push, and Sandbox.create() fails with image_not_ready until preparation finishes. Check the readiness state on the repository details page, then retry. Confirm the image was built with --platform linux/amd64.

Copy link to headingThe server never responds in Path A

Custom Sandbox images skip Docker ENTRYPOINT and CMD, so the server only runs if you started it with runCommand(). Start it explicitly with detached: true, and confirm it listens on a port you declared in the ports array.

Copy link to headingTLS failures inside containers in Path B

The sandbox trusts its proxy CA certificate at /etc/pki/ca-trust/source/anchors/vercel-proxy-ca.pem, but containers can't see it. Mount the certificate into the container and add it to the container's trust store:

sudo docker run --rm \
-v /etc/pki/ca-trust/source/anchors/vercel-proxy-ca.pem:/usr/local/share/ca-certificates/vercel-proxy-ca.crt:ro \
my-image \
sh -c "update-ca-certificates && my-command"

Use update-ca-certificates on Debian or Ubuntu base images and update-ca-trust on Amazon Linux, Fedora, or RHEL. If the app reads a CA bundle from an environment variable, point that variable (such as NODE_EXTRA_CA_CERTS) at the mounted path instead.

Copy link to headingThe sandbox times out mid-validation

Sandboxes run for 5 minutes by default. Pass a longer timeout at creation, or call sandbox.extendTimeout() while running. The ceiling is 45 minutes on Hobby plans and 24 hours on Pro and Enterprise plans.

Copy link to headingBest practices

  • Pin by digest, not tag: Validate my-repository@sha256:... so the artifact you tested is byte-for-byte the one you deploy. Tags can move between validation and deploy.
  • Keep runs cheap: Data a sandbox downloads (packages, images, and Git repositories) is free, but traffic on exposed ports and data sent out is billable per GB, so keep smoke payloads small. Sandbox creation itself costs $0.60 per million creations.
  • Reuse persistent sandboxes for Path B: The Docker installation and pulled images survive between sessions, which skips 30 or more seconds of setup on every run. Use Sandbox.getOrCreate() with an onResume hook that restarts dockerd.
  • Fail loudly: Exit nonzero on any failed check so CI blocks the deploy, and print the container logs on failure so the run is debuggable from CI output alone.

More Vercel Container Registry guides