Setting up Expo with Blaxel Sandboxes
Last updated: January 19, 2026
This guide explains how to run an Expo (React Native / Web) application inside a Blaxel Sandbox and expose it securely using Blaxel Previews.
Overview
The guide covers:
Building a sandbox image for Expo
Creating or reusing a Blaxel sandbox
Configuring Expo to work behind Blaxel previews
Launching Expo and accessing it through a preview URL
Accessing the app on mobile devices via QR code
Prerequisites
Before starting, ensure you have:
Blaxel CLI installed and authenticated (
bl login)Node.js 18+ installed
@blaxel/corepackage installed in your project (npm install @blaxel/core)
Architecture Overview
Running Expo inside a Blaxel sandbox requires a few adjustments compared to local development:
Expo normally binds to local or internal URLs
Blaxel exposes services via preview URLs
Expo must be explicitly configured to use those preview URLs
This is solved by:
Injecting the preview URL into
app.jsonSetting
EXPO_PACKAGER_PROXY_URLto force Expo to proxy assets through the preview URLRestarting the dev server after configuration changes
Exposing the Expo dev server via a Blaxel Preview
Sandbox Image for Expo
Dockerfile
FROM node:22-alpine
RUN apk update && apk add --no-cache \
git \
curl \
netcat-openbsd \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY --from=ghcr.io/blaxel-ai/sandbox:latest /sandbox-api /usr/local/bin/sandbox-api
# Create Expo project with default template
RUN npx create-expo-app@latest .
# Install web dependencies required for Expo web support
RUN npx expo install react-dom react-native-web
# Pre-warm Metro bundler cache by starting the dev server briefly
# This warms up the cache better than export since it matches actual dev usage
RUN timeout 120 npx expo start --web --port 8081 --no-dev-client 2>/dev/null || true
# Add npm global modules to PATH
ENV PATH="/usr/local/bin:$PATH"
ENTRYPOINT ["/usr/local/bin/sandbox-api"]blaxel.toml
Create a blaxel.toml file in the same directory as your Dockerfile:
type = "sandbox"
name = "expo-template"
[runtime]
memory = 8192
[[runtime.ports]]
name = "expo-web"
target = 8081Deploying the Image
Deploy the image by running:
bl deployCreating or Reusing a Sandbox
import { SandboxInstance } from "@blaxel/core";
const sandboxName = "my-expo-sandbox";
const sandbox = await SandboxInstance.createIfNotExists({
name: sandboxName,
labels: {
framework: "expo",
},
image: "expo-template:latest",
memory: 8192,
ports: [
{ name: "preview", target: 8081, protocol: "HTTP" },
],
});Configuring CORS for Expo Preview
Expo dev servers require permissive CORS headers when accessed through a preview URL:
const responseHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-Requested-With, X-Blaxel-Workspace, X-Blaxel-Preview-Token, X-Blaxel-Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "Content-Length, X-Request-Id",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
};Alternatively, you can use custom domains to expose previews on your own domain.
Creating the Blaxel Preview
Expo runs on port 8081, so we expose that port via a preview:
const preview = await sandbox.previews.createIfNotExists({
metadata: { name: "dev-server-preview" },
spec: {
responseHeaders,
public: false,
port: 8081,
},
});Generating a Preview Token
To securely access the preview, a token is required:
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 1 day
const token = await preview.tokens.create(expiresAt);Configuring Expo for Blaxel Previews
Injecting the Preview URL into app.json
Expo Router requires the correct origin when running behind a proxy:
async function addRouterOriginToAppJson(
sandbox: SandboxInstance,
previewUrl: string
) {
const appJsonPath = "/app/app.json";
const appJsonContent = await sandbox.fs.read(appJsonPath);
const appJson = JSON.parse(appJsonContent);
appJson.expo = {
...appJson.expo,
extra: {
...(appJson.expo.extra || {}),
router: {
...(appJson.expo.extra?.router || {}),
origin: previewUrl,
},
},
};
await sandbox.fs.write(appJsonPath, JSON.stringify(appJson, null, 2));
}Configuring the Proxy URL
Expo must be configured to serve assets through the preview URL. This function checks if the configuration is already correct and only updates if needed:
async function configureExpoProxyUrl(
sandbox: SandboxInstance,
previewUrl: string
): Promise<boolean> {
const baseUrl = previewUrl.replace(/\/$/, ""); // Remove trailing slash
// Check if the .env file already has the correct proxy URL
let envContent = "";
try {
envContent = await sandbox.fs.read("/app/.env");
} catch {
// File doesn't exist yet
}
const expectedEnvLine = `EXPO_PACKAGER_PROXY_URL=${baseUrl}`;
if (envContent.includes(expectedEnvLine)) {
console.log("Expo proxy URL already configured correctly");
return false; // No changes made
}
// Remove any existing EXPO_PACKAGER_PROXY_URL line and add the new one
const lines = envContent
.split("\n")
.filter((line) => !line.startsWith("EXPO_PACKAGER_PROXY_URL="));
lines.push(expectedEnvLine);
await sandbox.fs.write("/app/.env", lines.join("\n"));
console.log(`Configured Expo to use proxy URL: ${baseUrl}`);
return true; // Changes were made
}Start the dev Server
After setting the proxy URL you can start the dev server:
async function startDevServer(sandbox: SandboxInstance) {
// Start a new dev server with the updated environment
console.log("Starting dev server with updated configuration...");
await sandbox.process.exec({
name: "dev-server",
command: "npx expo start --web --port 8081 --scheme exp",
workingDir: "/app",
waitForPorts: [8081],
restartOnFailure: true,
maxRestarts: 25,
});
}Streaming Expo Logs
To monitor the Expo dev server output in real-time:
const logStream = sandbox.process.streamLogs("dev-server", {
onLog(log) {
console.log(log);
},
});
// When done monitoring, close the stream:
logStream.close();Accessing the Expo App
Web Browser
Once everything is running, the app is available at:
${preview.spec?.url}?bl_preview_token=${token.value}Mobile Device via QR Code
To access the Expo app on your mobile device:
Generate a QR code containing the Expo URL format:
// Build the URL with authentication token
const previewUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`;
// Convert to Expo URL format (replace https:// with exp://)
const expoUrl = previewUrl.replace("https://", "exp://");The QR code should encode the
exp://URL, for example:
exp://your-sandbox.preview.blaxel.ai/?bl_preview_token=your-tokenScan the QR code with:
Your device’s camera (iOS/Android)
The Expo Go app
Complete Example
Here is a full runnable example combining all the steps:
import { SandboxInstance } from "@blaxel/core";
const sandboxName = "my-expo-sandbox";
const responseHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-Requested-With, X-Blaxel-Workspace, X-Blaxel-Preview-Token, X-Blaxel-Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "Content-Length, X-Request-Id",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
};
async function addRouterOriginToAppJson(
sandbox: SandboxInstance,
previewUrl: string
) {
const appJsonPath = "/app/app.json";
const appJsonContent = await sandbox.fs.read(appJsonPath);
const appJson = JSON.parse(appJsonContent);
appJson.expo = {
...appJson.expo,
extra: {
...(appJson.expo.extra || {}),
router: {
...(appJson.expo.extra?.router || {}),
origin: previewUrl,
},
},
};
await sandbox.fs.write(appJsonPath, JSON.stringify(appJson, null, 2));
}
async function configureExpoProxyUrl(
sandbox: SandboxInstance,
previewUrl: string
): Promise<boolean> {
const baseUrl = previewUrl.replace(/\/$/, "");
let envContent = "";
try {
envContent = await sandbox.fs.read("/app/.env");
} catch {
// File doesn't exist
}
const expectedEnvLine = `EXPO_PACKAGER_PROXY_URL=${baseUrl}`;
if (envContent.includes(expectedEnvLine)) {
return false;
}
const lines = envContent
.split("\n")
.filter((line) => !line.startsWith("EXPO_PACKAGER_PROXY_URL="));
lines.push(expectedEnvLine);
await sandbox.fs.write("/app/.env", lines.join("\n"));
return true;
}
async function startDevServer(sandbox: SandboxInstance) {
await sandbox.process.exec({
name: "dev-server",
command: "npx expo start --web --port 8081 --scheme exp",
workingDir: "/app",
waitForPorts: [8081],
restartOnFailure: true,
maxRestarts: 25,
});
}
async function configureExpo(sandbox: SandboxInstance, previewUrl: string) {
await addRouterOriginToAppJson(sandbox, previewUrl);
const proxyUrlChanged = await configureExpoProxyUrl(sandbox, previewUrl);
await startDevServer(sandbox)
}
async function main() {
try {
// Create or reuse the sandbox
const sandbox = await SandboxInstance.createIfNotExists({
name: sandboxName,
labels: {
framework: "expo",
},
image: "expo-template:latest",
memory: 8192,
ports: [
{ name: "preview", target: 8081, protocol: "HTTP" },
]
});
// Create preview
const preview = await sandbox.previews.createIfNotExists({
metadata: { name: "preview" },
spec: {
responseHeaders,
public: false,
port: 8081,
},
});
// Generate preview token
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24);
const token = await preview.tokens.create(expiresAt);
// Configure Expo (will restart dev server if needed)
await configureExpo(sandbox, preview.spec?.url!);
// Start dev server if not already running
const processes = await sandbox.process.list();
if (!processes.find((p) => p.name === "dev-server")) {
await sandbox.process.exec({
name: "dev-server",
command: "npx expo start --web --port 8081 --scheme exp",
workingDir: "/app",
waitForPorts: [8081],
restartOnFailure: true,
maxRestarts: 25,
});
}
// Print access URLs
const webUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`;
const expoUrl = webUrl.replace("https://", "exp://");
console.log(`Web Preview URL: ${webUrl}`);
console.log(`Expo Mobile URL: ${expoUrl}`);
// Stream logs
const logStream = sandbox.process.streamLogs("dev-server", {
onLog(log) {
console.log(log)
},
});
// Keep running until interrupted
process.on("SIGINT", () => {
logStream.close();
process.exit(0);
});
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
main();Summary
This setup allows you to:
Run Expo fully inside Blaxel sandboxes
Securely expose the dev server using Blaxel previews
Support Expo Router and asset loading correctly
Access the app on mobile devices via QR code using the
exp://protocolAutomatically restart the dev server when configuration changes
This approach is ideal for preview environments, internal demos, and AI-powered coding workflows built on Blaxel.