feat: pre-built Docker image for OpenClaw on Fly.io (#1686)

Eliminates the slow waitForCloudInit() + bun install phase by booting
a pre-built image with Node.js, bun, and openclaw already installed.
The image is rebuilt daily via GitHub Actions to pick up new releases.

Other agents are unaffected — they still use ubuntu:24.04 + cloud-init.

Co-authored-by: spawn-bot <spawn-bot@openrouter.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-21 23:50:46 -08:00 committed by GitHub
parent e381ca2412
commit 0f4df7be71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 95 additions and 11 deletions

34
.github/workflows/fly-docker.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Build Fly Docker Images
on:
push:
branches: [main]
paths:
- "fly/docker/openclaw.Dockerfile"
schedule:
# Daily: pick up new openclaw releases
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
packages: write
contents: read
jobs:
build-openclaw:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: fly/docker/openclaw.Dockerfile
push: true
tags: ghcr.io/openrouterteam/spawn-openclaw:latest

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.5.33",
"version": "0.5.34",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -21,6 +21,8 @@ import {
export interface AgentConfig {
name: string;
/** Custom Docker image for the Fly machine (default: ubuntu:24.04). */
image?: string;
/** If true, prompt for model selection before provisioning. */
modelPrompt?: boolean;
/** Default model ID when modelPrompt is true. */
@ -342,13 +344,22 @@ export const agents: Record<string, AgentConfig> = {
openclaw: {
name: "OpenClaw",
image: "ghcr.io/openrouterteam/spawn-openclaw:latest",
modelPrompt: true,
modelDefault: "openrouter/auto",
install: () =>
installAgent(
"openclaw",
'export PATH="$HOME/.bun/bin:$HOME/.local/bin:$PATH" && bun install -g openclaw && command -v openclaw',
),
install: async () => {
logStep("Verifying openclaw installation...");
try {
await runServer("command -v openclaw");
logInfo("openclaw is pre-installed");
} catch {
logInfo("openclaw not found in image, installing from scratch...");
await installAgent(
"openclaw",
'export PATH="$HOME/.bun/bin:$HOME/.local/bin:$PATH" && bun install -g openclaw && command -v openclaw',
);
}
},
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
`ANTHROPIC_API_KEY=${apiKey}`,

View file

@ -567,11 +567,12 @@ async function createMachine(
cpus: number,
vmMemory: number,
volumeId?: string,
image?: string,
): Promise<string> {
const kindLabel = cpuKind === "performance" ? "dedicated" : "shared";
logStep(`Creating Fly.io machine (region: ${region}, ${cpus} ${kindLabel} vCPU, ${vmMemory}MB)...`);
const config: Record<string, unknown> = {
image: "ubuntu:24.04",
image: image || "ubuntu:24.04",
guest: { cpu_kind: cpuKind, cpus, memory_mb: vmMemory },
init: { exec: ["/bin/sleep", "inf"] },
auto_destroy: false,
@ -675,7 +676,7 @@ export async function listVolumes(appName: string): Promise<Array<{ id: string;
}));
}
export async function createServer(name: string, opts: ServerOptions): Promise<void> {
export async function createServer(name: string, opts: ServerOptions, image?: string): Promise<void> {
const region = process.env.FLY_REGION || "iad";
if (!validateRegionName(region)) {
@ -698,7 +699,7 @@ export async function createServer(name: string, opts: ServerOptions): Promise<v
let machineId: string;
try {
machineId = await createMachine(name, region, opts.cpuKind, opts.cpus, opts.memoryMb, volumeId);
machineId = await createMachine(name, region, opts.cpuKind, opts.cpus, opts.memoryMb, volumeId, image);
} catch (err) {
await cleanupOnFailure(name);
throw err;

View file

@ -9,6 +9,7 @@ import {
createServer,
getServerName,
waitForCloudInit,
waitForSsh,
runServer,
interactiveSession,
saveLaunchCmd,
@ -89,10 +90,15 @@ async function main() {
// 6. Provision server
const serverName = await getServerName();
await createServer(serverName, serverOpts);
await createServer(serverName, serverOpts, agent.image);
// 7. Wait for readiness
await waitForCloudInit();
if (agent.image) {
// Custom image already has packages baked in — just wait for SSH
await waitForSsh();
} else {
await waitForCloudInit();
}
// 8. Install agent
await agent.install();

View file

@ -0,0 +1,32 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Base packages (matches waitForCloudInit in fly.ts)
RUN apt-get update -y && \
apt-get install -y --no-install-recommends \
curl git ca-certificates build-essential unzip xz-utils zsh && \
rm -rf /var/lib/apt/lists/*
# Node.js 22 via apt + n (matches fly.ts cloud-init strategy)
RUN apt-get update -y && \
apt-get install -y --no-install-recommends nodejs npm && \
npm install -g n && n 22 && \
ln -sf /usr/local/bin/node /usr/bin/node && \
ln -sf /usr/local/bin/npm /usr/bin/npm && \
ln -sf /usr/local/bin/npx /usr/bin/npx && \
rm -rf /var/lib/apt/lists/*
# Bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:/root/.local/bin:${PATH}"
# OpenClaw via bun (matches agents.ts install strategy)
RUN bun install -g openclaw
# Ensure tools are on PATH for all shells
RUN for rc in /root/.bashrc /root/.zshrc; do \
grep -q '.bun/bin' "$rc" 2>/dev/null || \
echo 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"' >> "$rc"; \
done
CMD ["/bin/sleep", "inf"]