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

Static frontend, container backend - 图1Docs

{"@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


Skip to content

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.