Static frontend, container backend
A simple frontend app with a containerized backend
Last updated Oct 12, 2026
Serve a static frontend with Workers Static Assets. Route API requests to a pool of three Containers using the durable_object scheduling policy.
Configure the Worker
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "static-frontend-container-backend",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"observability": {
"enabled": true
},
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": [
"/api",
"/api/*"
]
},
"containers": [
{
"class_name": "Backend",
"scheduling_policy": "durable_object",
"images": {
"base": {
"dockerfile": "./Dockerfile"
}
}
}
],
"durable_objects": {
"bindings": [
{
"name": "BACKEND",
"class_name": "Backend"
}
]
},
"exports": {
"Backend": {
"type": "durable-object",
"storage": "sqlite"
}
}
}
name = "static-frontend-container-backend"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
[observability]
enabled = true
[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = ["/api", "/api/*"]
[[containers]]
class_name = "Backend"
scheduling_policy = "durable_object"
[containers.images.base]
dockerfile = "./Dockerfile"
[[durable_objects.bindings]]
name = "BACKEND"
class_name = "Backend"
[exports.Backend]
type = "durable-object"
storage = "sqlite"
Add the frontend
Create a simple index.html file in the ./dist directory.
<details>
<summary>
index.html
</summary>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widgets</title>
<script
defer
src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.13.3/cdn.min.js"
></script>
</head>
<body>
<div x-data="widgets()" x-init="fetchWidgets()">
<h1>Widgets</h1>
<div x-show="loading">Loading...</div>
<div x-show="error" x-text="error" style="color: red;"></div>
<ul x-show="!loading && !error">
<template x-for="widget in widgets" :key="widget.id">
<li>
<span x-text="widget.name"></span> - (ID:
<span x-text="widget.id"></span>)
</li>
</template>
</ul>
<div x-show="!loading && !error && widgets.length === 0">
No widgets found.
</div>
</div>
<script>
function widgets() {
return {
widgets: [],
loading: false,
error: null,
async fetchWidgets() {
this.loading = true;
this.error = null;
try {
const response = await fetch("/api/widgets");
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`,
);
}
this.widgets = await response.json();
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
};
}
</script>
</body>
</html>
</details>
The frontend uses Alpine.js ↗︎ to fetch a list of widgets from /api/widgets.
This is meant to be a very simple example, but you can get significantly more complex. See examples of Workers integrating with frontend frameworks for more information.
Route API requests
start() initiates startup. The Worker waits for a successful /health response before forwarding traffic. Concurrent requests share that readiness check, and a failed check can be retried by the next request.
src/index.jsjs
import { DurableObject } from "cloudflare:workers";
const INSTANCE_COUNT = 3;
const INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000;
export class Backend extends DurableObject {
starting;
constructor(ctx, env) {
super(ctx, env);
const container = ctx.container;
if (container.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
);
}
}
async fetch(request) {
// Concurrent requests share startup and readiness checks.
this.starting ??= this.startAndWaitForPort().finally(() => {
this.starting = undefined;
});
await this.starting;
const url = new URL(request.url);
url.protocol = "http:";
url.host = "container";
const forwarded = new Request(url, request);
forwarded.headers.delete("host");
return this.ctx.container.getTcpPort(8080).fetch(forwarded);
}
async startAndWaitForPort() {
const container = this.ctx.container;
if (!container.running) {
container.start({
image: container.images.base,
instance: "lite",
enableInternet: false,
});
}
await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
const port = container.getTcpPort(8080);
let lastError;
for (let attempt = 0; attempt < 100; attempt++) {
try {
const response = await port.fetch("http://container/health", {
signal: AbortSignal.timeout(1000),
});
await response.body?.cancel();
if (!response.ok) {
throw new Error(`Health check returned ${response.status}`);
}
return;
} catch (error) {
lastError = error;
await scheduler.wait(200);
}
}
throw new Error("Container did not become ready on port 8080", {
cause: lastError,
});
}
}
export default {
fetch(request, env) {
const pathname = new URL(request.url).pathname;
if (pathname === "/api" || pathname.startsWith("/api/")) {
const index = Math.floor(Math.random() * INSTANCE_COUNT);
return env.BACKEND.getByName(`instance-${index}`).fetch(request);
}
return env.ASSETS.fetch(request);
},
};
src/index.tsts
import { DurableObject } from "cloudflare:workers";
interface Env {
BACKEND: DurableObjectNamespace<Backend>;
ASSETS: Fetcher;
}
const INSTANCE_COUNT = 3;
const INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000;
export class Backend extends DurableObject<Env> {
private starting: Promise<void> | undefined;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
const container = ctx.container!;
if (container.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
);
}
}
async fetch(request: Request): Promise<Response> {
// Concurrent requests share startup and readiness checks.
this.starting ??= this.startAndWaitForPort().finally(() => {
this.starting = undefined;
});
await this.starting;
const url = new URL(request.url);
url.protocol = "http:";
url.host = "container";
const forwarded = new Request(url, request);
forwarded.headers.delete("host");
return this.ctx.container!.getTcpPort(8080).fetch(forwarded);
}
private async startAndWaitForPort(): Promise<void> {
const container = this.ctx.container!;
if (!container.running) {
container.start({
image: container.images.base,
instance: "lite",
enableInternet: false,
});
}
await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
const port = container.getTcpPort(8080);
let lastError: unknown;
for (let attempt = 0; attempt < 100; attempt++) {
try {
const response = await port.fetch("http://container/health", {
signal: AbortSignal.timeout(1000),
});
await response.body?.cancel();
if (!response.ok) {
throw new Error(`Health check returned ${response.status}`);
}
return;
} catch (error) {
lastError = error;
await scheduler.wait(200);
}
}
throw new Error("Container did not become ready on port 8080", {
cause: lastError,
});
}
}
export default {
fetch(request: Request, env: Env): Promise<Response> {
const pathname = new URL(request.url).pathname;
if (pathname === "/api" || pathname.startsWith("/api/")) {
const index = Math.floor(Math.random() * INSTANCE_COUNT);
return env.BACKEND.getByName(`instance-${index}`).fetch(request);
}
return env.ASSETS.fetch(request);
},
} satisfies ExportedHandler<Env>;
The Worker selects one of three named instances for each API request. Each instance uses the configured base image and the lite instance size.
Define a backend container
Your container should be able to handle requests to /api/widgets.
In this case, we'll use a simple Golang backend that returns a hard-coded list of widgets.
<details>
<summary>
server.go
</summary>
package main
import (
"encoding/json"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
widgets := []map[string]interface{}{
{"id": 1, "name": "Widget A"},
{"id": 2, "name": "Sprocket B"},
{"id": 3, "name": "Gear C"},
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(widgets)
}
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
http.HandleFunc("/api/widgets", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
</details>
The health endpoint lets the Worker wait for the backend before forwarding requests. Build the backend image with this Dockerfile in the project root:
<details>
<summary>
Dockerfile
</summary>
FROM golang:1.25-alpine AS build
WORKDIR /app
COPY server.go .
RUN CGO_ENABLED=0 go build -o /server server.go
FROM alpine:3.20
COPY --from=build /server /server
EXPOSE 8080
CMD ["/server"]
</details>
Run the example
Start a Docker-compatible engine. Install Wrangler in your project, using version 4.136.0 or later for local development.
npmyarnpnpmbun
npm i -D wrangler
yarn add -D wrangler
pnpm add -D wrangler
bun add -d wrangler
npmyarnpnpm
npx wrangler dev
yarn wrangler dev
pnpm wrangler dev
Visit http://localhost:8787/ to load the frontend and its list of widgets.
Was this helpful?
YesNo
On this page
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/containers/examples/container-backend/#page","headline":"Static frontend, container backend","description":"A simple frontend app with a containerized backend","url":"https://developers.cloudflare.com/containers/examples/container-backend/","inLanguage":"en","image":"https://developers.cloudflare.com/containers/examples/container-backend/og.png?v=bad3f19d23bc5627","dateModified":"2026-10-12","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
description: Running a container on a schedule using Cron Triggers title: Cron container image: https://developers.cloudflare.com/containers/examples/cron/og.png?v=8aa941b4f4fd1c08
Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/containers/llms.txt
Use this file to discover all available pages before exploring further.
Docs