Building a Full-Stack Application
Section titled “Building a Full-Stack Application”The conventional route to a full-stack deployment runs through months of Terraform, Kubernetes manifests, CI/CD pipelines, reverse proxy configs, and SSL certificates, with no guarantee that staging matches production. On Hoody, the same result is two containers, a handful of HTTP calls, and a URL you can share the same day.
This guide builds a full-stack application from scratch: a React frontend, a TypeScript API backend, a SQLite database with session management, and a production domain. Everything runs inside containers where every service is already an HTTP endpoint, so there is no Docker, Nginx, or deploy script to manage. The pieces compose as URLs calling URLs.
The architecture
Section titled “The architecture”Here is what you are building:
your-app.com (Proxy Alias) | ┌────────────┴────────────┐ v v ┌──────────────┐ ┌──────────────┐ │ Frontend │ │ Backend │ │ Container │ HTTP │ Container │ │ │ ──────> │ │ │ hoody-daemon│ │ hoody-exec │ │ (React app) │ │ (API routes)│ │ │ │ hoody-sqlite│ │ hoody-code │ │ (database) │ │ (VS Code) │ │ │ └──────────────┘ └──────────────┘The whole app is two containers in one project. Every arrow in the diagram is an HTTP call, and every box is a URL.
Step 1: Create the project and containers
Section titled “Step 1: Create the project and containers”First, create a project to organize your full-stack app.
# Create the projecthoody projects create --alias "my-saas-app"
# Create the backend containerhoody containers create --project $PROJECT_ID \ --server-id $SERVER_ID \ --name "backend" \ --container-image "debian/13" \ --hoody-kit
# Create the frontend containerhoody containers create --project $PROJECT_ID \ --server-id $SERVER_ID \ --name "frontend" \ --container-image "debian/13" \ --hoody-kitimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
// Create the projectconst project = await client.api.projects.create({ alias: 'my-saas-app' });
// Create backend containerconst backend = await client.api.containers.create(project.data.id, { name: 'backend', server_id: SERVER_ID, container_image: 'debian/13', hoody_kit: true,});
// Create frontend containerconst frontend = await client.api.containers.create(project.data.id, { name: 'frontend', server_id: SERVER_ID, container_image: 'debian/13', hoody_kit: true,});
console.log('Backend:', backend.data.id);console.log('Frontend:', frontend.data.id);# Create the projectcurl -X POST "https://api.hoody.com/api/v1/projects/" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"alias": "my-saas-app"}'
# Create backend containercurl -X POST "https://api.hoody.com/api/v1/projects/$PROJECT_ID/containers" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "backend", "server_id": "'$SERVER_ID'", "container_image": "debian/13", "hoody_kit": true }'
# Create frontend containercurl -X POST "https://api.hoody.com/api/v1/projects/$PROJECT_ID/containers" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "frontend", "server_id": "'$SERVER_ID'", "container_image": "debian/13", "hoody_kit": true }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Each call as a single GET URL. Neither the project nor the backend container
exists yet to relay it through, so create both from one of the other tabs
first; only the frontend container create can then run as a link, through the
backend’s own curl-1.
# Create project
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/projects/&method=POST&bearer_token=HOODY_TOKEN&json={"alias":"my-saas-app"}&response=transparent
# Create backend container
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/projects/PROJECT_ID/containers&method=POST&bearer_token=HOODY_TOKEN&json={"name":"backend","server_id":"SERVER_ID","container_image":"debian/13","hoody_kit":true}&response=transparent
# Create frontend container
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/projects/PROJECT_ID/containers&method=POST&bearer_token=HOODY_TOKEN&json={"name":"frontend","server_id":"SERVER_ID","container_image":"debian/13","hoody_kit":true}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
You now have two containers, each with the full Hoody Kit HTTP stack running. Creating them takes seconds.
Step 2: Build the backend API
Section titled “Step 2: Build the backend API”The backend lives entirely inside hoody-exec: you write functions in files and they become HTTP endpoints. There is no Express, Fastify, or server configuration.
Create the database schema
Section titled “Create the database schema”Use hoody-sqlite to set up your data layer:
# Create the users table (--create-db-if-missing creates app.db on first use)hoody db exec-transaction -c $BACKEND_ID --db /hoody/databases/app.db --create-db-if-missing \ --transaction '[{"statement": "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"}]'
# Create the posts tablehoody db exec-transaction -c $BACKEND_ID --db /hoody/databases/app.db \ --transaction '[{"statement": "CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, body TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"}]'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
const containerClient = await client.withContainer({ id: BACKEND_ID, project_id: PROJECT_ID, server: SERVER,});
// Call the SQLite service directly over HTTP; it's just another URL.// The `db` query param is required; create_db_if_missing=true creates app.db on first use.const sqliteUrl = `https://${PROJECT_ID}-${BACKEND_ID}-sqlite-1.${SERVER}.containers.hoody.com`;
await fetch(`${sqliteUrl}/api/v1/sqlite/db?db=/hoody/databases/app.db&create_db_if_missing=true`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction: [ { statement: `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )` } ], })});
await fetch(`${sqliteUrl}/api/v1/sqlite/db?db=/hoody/databases/app.db`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction: [ { statement: `CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, body TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )` } ], })});# Create users table (create_db_if_missing=true creates app.db on first use)curl -X POST "https://$PROJECT_ID-$BACKEND_ID-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db&create_db_if_missing=true" \ -H "Content-Type: application/json" \ -d '{ "transaction": [{"statement": "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"}] }'
# Create posts tablecurl -X POST "https://$PROJECT_ID-$BACKEND_ID-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db" \ -H "Content-Type: application/json" \ -d '{ "transaction": [{"statement": "CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, body TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"}] }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Both table creates as links, run through the backend’s own curl-1.
create_db_if_missing=true only matters on the first call; app.db already
exists by the second.
# Create users table
https://PROJECT_ID-BACKEND_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-BACKEND_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db%26create_db_if_missing=true&method=POST&json={"transaction":[{"statement":"CREATE%20TABLE%20IF%20NOT%20EXISTS%20users%20(id%20INTEGER%20PRIMARY%20KEY%20AUTOINCREMENT,%20email%20TEXT%20UNIQUE%20NOT%20NULL,%20name%20TEXT%20NOT%20NULL,%20created_at%20DATETIME%20DEFAULT%20CURRENT_TIMESTAMP)"}]}&response=transparent
# Create posts table
https://PROJECT_ID-BACKEND_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-BACKEND_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db&method=POST&json={"transaction":[{"statement":"CREATE%20TABLE%20IF%20NOT%20EXISTS%20posts%20(id%20INTEGER%20PRIMARY%20KEY%20AUTOINCREMENT,%20user_id%20INTEGER%20REFERENCES%20users(id),%20title%20TEXT%20NOT%20NULL,%20body%20TEXT%20NOT%20NULL,%20created_at%20DATETIME%20DEFAULT%20CURRENT_TIMESTAMP)"}]}&response=transparent Set up session storage with KV
Section titled “Set up session storage with KV”Use the KV store built into hoody-sqlite for session management; there is no Redis, Memcached, or third service to run:
# Store a session tokenhoody kv set "session:abc123" -c $BACKEND_ID \ --db /hoody/databases/app.db \ --body '{"user_id": 1, "expires": "2026-04-01T00:00:00Z"}'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
const containerClient = await client.withContainer({ id: BACKEND_ID, project_id: PROJECT_ID, server: SERVER,});
await containerClient.sqlite.kvStore.set('session:abc123', JSON.stringify({ user_id: 1, expires: '2026-04-01T00:00:00Z' }), { db: '/hoody/databases/app.db' });# The request body itself is the value (raw string), not a JSON wrapper. `db` is required.curl -X PUT "https://$PROJECT_ID-$BACKEND_ID-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/session:abc123?db=/hoody/databases/app.db" \ -H "Content-Type: application/json" \ -d '{"user_id": 1, "expires": "2026-04-01T00:00:00Z"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Sets one KV value as a single link. The body is the raw value itself, not a JSON wrapper, so a different value must be pasted here as raw text too.
https://PROJECT_ID-BACKEND_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-BACKEND_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/session:abc123?db=/hoody/databases/app.db&method=PUT&header=Content-Type:%20application/json&data={"user_id":%201,%20"expires":%20"2026-04-01T00:00:00Z"}&response=transparent Write the API routes
Section titled “Write the API routes”Create your API endpoint scripts. Each file becomes a URL automatically:
# Build the URL first so the embedded value is concreteSQLITE_URL="https://$PROJECT_ID-$BACKEND_ID-sqlite-1.$SERVER.containers.hoody.com"
# Write the users endpointhoody exec scripts write -c $BACKEND_ID \ --path "api/users.ts" \ --content "// @mode serverless\n// @cors reflective\n// @timeout 5000\n\nconst SQLITE_URL = \"$SQLITE_URL\";\n\nif (metadata.method === \"GET\") {\n const result = await fetch(SQLITE_URL + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ transaction: [{ query: \"SELECT * FROM users ORDER BY created_at DESC\" }] })\n });\n return await result.json();\n}\n\nif (metadata.method === \"POST\") {\n const { email, name } = req.body;\n const result = await fetch(SQLITE_URL + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ transaction: [{ query: \"INSERT INTO users (email, name) VALUES (?, ?)\", values: [email, name] }] })\n });\n return await result.json();\n}" \ --create-dirsimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
const containerClient = await client.withContainer({ id: BACKEND_ID, project_id: PROJECT_ID, server: SERVER,});
const sqliteUrl = `https://${PROJECT_ID}-${BACKEND_ID}-sqlite-1.${SERVER}.containers.hoody.com`;
await containerClient.exec.scripts.write({ path: 'api/users.ts', content: `// @mode serverless// @cors reflective// @timeout 5000
const SQLITE_URL = "${sqliteUrl}";
if (metadata.method === "GET") { const result = await fetch(SQLITE_URL + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: "SELECT * FROM users ORDER BY created_at DESC" }] }) }); return await result.json();}
if (metadata.method === "POST") { const { email, name } = req.body; const result = await fetch(SQLITE_URL + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: "INSERT INTO users (email, name) VALUES (?, ?)", values: [email, name] }] }) }); return await result.json();} `, createDirs: true,});# Build the script payload from real env vars so the embedded URL is concrete.SQLITE_URL="https://$PROJECT_ID-$BACKEND_ID-sqlite-1.$SERVER.containers.hoody.com"SCRIPT_CONTENT="// @mode serverless\n// @cors reflective\n// @timeout 5000\n\nconst SQLITE_URL = \"$SQLITE_URL\";\n\nif (metadata.method === \"GET\") {\n const result = await fetch(SQLITE_URL + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ transaction: [{ query: \"SELECT * FROM users ORDER BY created_at DESC\" }] })\n });\n return await result.json();\n}\n\nif (metadata.method === \"POST\") {\n const { email, name } = req.body;\n const result = await fetch(SQLITE_URL + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n transaction: [{ query: \"INSERT INTO users (email, name) VALUES (?, ?)\", values: [email, name] }]\n })\n });\n return await result.json();\n}"
curl -X POST "https://$PROJECT_ID-$BACKEND_ID-exec-1.$SERVER.containers.hoody.com/api/v1/exec/scripts/write" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg p "api/users.ts" --arg c "$SCRIPT_CONTENT" '{path:$p,content:$c,createDirs:true}')"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Writes the endpoint file itself as the request body; the route goes live at
/api/users the moment the call lands, with no build step or restart.
https://PROJECT_ID-BACKEND_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-BACKEND_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/scripts/write&method=POST&json={"path":"api/users.ts","content":"//%20@mode%20serverless\n//%20@cors%20reflective\n//%20@timeout%205000\n\nconst%20SQLITE_URL%20=%20\"https://PROJECT_ID-BACKEND_ID-sqlite-1.SERVER.containers.hoody.com\";\n\nif%20(metadata.method%20===%20\"GET\")%20{\nconst%20result%20=%20await%20fetch(SQLITE_URL%20%2B%20\"/api/v1/sqlite/db?db=/hoody/databases/app.db\",%20{\n%20%20method:%20\"POST\",\n%20%20headers:%20{%20\"Content-Type\":%20\"application/json\"%20},\n%20%20body:%20JSON.stringify({%20transaction:%20[{%20query:%20\"SELECT%20*%20FROM%20users%20ORDER%20BY%20created_at%20DESC\"%20}]%20})\n});\nreturn%20await%20result.json();\n}\n\nif%20(metadata.method%20===%20\"POST\")%20{\nconst%20{%20email,%20name%20}%20=%20req.body;\nconst%20result%20=%20await%20fetch(SQLITE_URL%20%2B%20\"/api/v1/sqlite/db?db=/hoody/databases/app.db\",%20{\n%20%20method:%20\"POST\",\n%20%20headers:%20{%20\"Content-Type\":%20\"application/json\"%20},\n%20%20body:%20JSON.stringify({\n%20%20%20%20transaction:%20[{%20query:%20\"INSERT%20INTO%20users%20(email,%20name)%20VALUES%20(?,%20?)\",%20values:%20[email,%20name]%20}]\n%20%20})\n});\nreturn%20await%20result.json();\n}","createDirs":true}&response=transparent That script is now live at:
https://$PROJECT_ID-$BACKEND_ID-exec-1.$SERVER.containers.hoody.com/api/usersThere is no deployment, build step, or restart: the file is the API.
Step 3: Scaffold the frontend
Section titled “Step 3: Scaffold the frontend”Use hoody-terminal to scaffold a React app inside the frontend container:
# Bun ships with dev_kit (on by default); this line is only needed if it was disabled.# --ephemeral auto-generates an isolated session; without it --terminal-id is required.hoody terminal sessions exec -c $FRONTEND_ID --ephemeral \ --command "curl -fsSL https://bun.sh/install | bash && \ bun create vite my-app --template react-ts && \ cd my-app && bun install"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
const containerClient = await client.withContainer({ id: FRONTEND_ID, project_id: PROJECT_ID, server: SERVER,});
// Scaffold the React app. Bun is already present unless dev_kit was disabled.// ephemeral=true spins up a guaranteed-unique// isolated PTY (no display/dbus, auto-cleanup), ideal for one-shot commands.await containerClient.terminal.execution.execute({ command: `curl -fsSL https://bun.sh/install | bash && \ bun create vite my-app --template react-ts && \ cd my-app && bun install`,}, { ephemeral: true });# ephemeral=true creates a guaranteed-unique isolated PTY (no display/dbus, auto-cleanup).# Without it, terminal_id is required.curl -X POST "https://$PROJECT_ID-$FRONTEND_ID-terminal-1.$SERVER.containers.hoody.com/api/v1/terminal/execute?ephemeral=true" \ -H "Content-Type: application/json" \ -d '{ "command": "curl -fsSL https://bun.sh/install | bash && bun create vite my-app --template react-ts && cd my-app && bun install" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Runs the scaffold command as a single link, in a fresh ephemeral session each time; running it twice starts two isolated shells rather than reusing one.
https://PROJECT_ID-FRONTEND_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-FRONTEND_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute?ephemeral=true&method=POST&json={"command":"curl%20-fsSL%20https://bun.sh/install%20|%20bash%20%26%26%20bun%20create%20vite%20my-app%20--template%20react-ts%20%26%26%20cd%20my-app%20%26%26%20bun%20install"}&response=transparent Connect the frontend to the backend
Section titled “Connect the frontend to the backend”Configure the React app to call the backend API. The backend is just a URL, so there is no environment variable wiring or proxy configuration:
// src/api.ts (inside your React app)const API_BASE = 'https://PROJECT_ID-BACKEND_ID-exec-1.SERVER.containers.hoody.com';
export async function getUsers() { const res = await fetch(`${API_BASE}/api/users`); return res.json();}
export async function createUser(email: string, name: string) { const res = await fetch(`${API_BASE}/api/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, name }), }); return res.json();}Serve with hoody-daemon
Section titled “Serve with hoody-daemon”Use hoody-daemon to run the dev server as a managed background process:
# Start the React dev server as a daemonhoody daemon programs create -c $FRONTEND_ID \ --name "react-dev" \ --command "cd /root/my-app && bun run dev --host 0.0.0.0 --port 3000" \ --user rootimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
const containerClient = await client.withContainer({ id: FRONTEND_ID, project_id: PROJECT_ID, server: SERVER,});
await containerClient.daemon.programs.add({ name: 'react-dev', command: 'cd /root/my-app && bun run dev --host 0.0.0.0 --port 3000', user: 'root',});curl -X POST "https://$PROJECT_ID-$FRONTEND_ID-daemon-1.$SERVER.containers.hoody.com/api/v1/daemon/programs/add" \ -H "Content-Type: application/json" \ -d '{ "name": "react-dev", "command": "cd /root/my-app && bun run dev --host 0.0.0.0 --port 3000", "user": "root" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Starts the React dev server as a single link; hoody-daemon supervises it and restarts it if it crashes.
https://PROJECT_ID-FRONTEND_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-FRONTEND_ID-daemon-1.SERVER.containers.hoody.com/api/v1/daemon/programs/add&method=POST&json={"name":"react-dev","command":"cd%20/root/my-app%20%26%26%20bun%20run%20dev%20--host%200.0.0.0%20--port%203000","user":"root"}&response=transparent Your React app is now running. View it live in hoody-display or access it through the container URL.
Step 4: Set up a production domain
Section titled “Step 4: Set up a production domain”Use proxy aliases to turn the long ID-based container URLs into clean production domains.
# Create alias for the frontend (React dev server on port 3000)hoody proxy create \ --container-id $FRONTEND_ID \ --program http --port 3000 \ --alias "my-saas-app"
# Create alias for the APIhoody proxy create \ --container-id $BACKEND_ID \ --program exec --index 1 \ --alias "api-my-saas-app"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
// Frontend alias (React dev server on port 3000)await client.api.proxyAliases.create({ container_id: FRONTEND_ID, alias: 'my-saas-app', program: 'http', port: 3000,});
// API aliasawait client.api.proxyAliases.create({ container_id: BACKEND_ID, alias: 'api-my-saas-app', program: 'exec', index: 1,});# Frontend alias (React dev server on port 3000)curl -X POST "https://api.hoody.com/api/v1/proxy/aliases" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "container_id": "'$FRONTEND_ID'", "alias": "my-saas-app", "program": "http", "port": 3000 }'
# API aliascurl -X POST "https://api.hoody.com/api/v1/proxy/aliases" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "container_id": "'$BACKEND_ID'", "alias": "api-my-saas-app", "program": "exec", "index": 1 }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Creates each proxy alias as a link, in either order. The production domains from this section go live once the matching call lands.
# Frontend alias
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/proxy/aliases&method=POST&bearer_token=HOODY_TOKEN&json={"container_id":"FRONTEND_ID","alias":"my-saas-app","program":"http","port":3000}&response=transparent
# API alias
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/proxy/aliases&method=POST&bearer_token=HOODY_TOKEN&json={"container_id":"BACKEND_ID","alias":"api-my-saas-app","program":"exec","index":1}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Now your app is live at https://my-saas-app.$SERVER.containers.hoody.com with the API at https://api-my-saas-app.$SERVER.containers.hoody.com. You can also connect your own domain; see Connect Your Domain.
Step 5: Snapshot before going live
Section titled “Step 5: Snapshot before going live”Snapshot both containers before you go live:
# Snapshot backendhoody snapshots create -c $BACKEND_ID \ --alias "pre-launch-backend"
# Snapshot frontendhoody snapshots create -c $FRONTEND_ID \ --alias "pre-launch-frontend"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.containers.createSnapshot(BACKEND_ID, { alias: 'pre-launch-backend',});
await client.api.containers.createSnapshot(FRONTEND_ID, { alias: 'pre-launch-frontend',});# Snapshot backendcurl -X POST "https://api.hoody.com/api/v1/containers/$BACKEND_ID/snapshots" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"alias": "pre-launch-backend"}'
# Snapshot frontendcurl -X POST "https://api.hoody.com/api/v1/containers/$FRONTEND_ID/snapshots" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"alias": "pre-launch-frontend"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Snapshots each container as a link, in either order. Run both before launch so a restore has a clean pre-launch state to return to.
# Snapshot backend
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/BACKEND_ID/snapshots&method=POST&bearer_token=HOODY_TOKEN&json={"alias":"pre-launch-backend"}&response=transparent
# Snapshot frontend
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/FRONTEND_ID/snapshots&method=POST&bearer_token=HOODY_TOKEN&json={"alias":"pre-launch-frontend"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
If something breaks after launch, restore both containers in seconds. The entire application (code, database, and configuration) rolls back to the moment you snapshotted.
The complete architecture
Section titled “The complete architecture”Here is your full-stack application as a service composition:
┌──────────────────────────────────────────────────────────┐│ HOODY PROJECT ││ "my-saas-app" ││ ││ ┌────────────────────┐ ┌─────────────────────────┐ ││ │ FRONTEND CONTAINER│ │ BACKEND CONTAINER │ ││ │ │ │ │ ││ │ terminal-1 │ │ exec-1 ← API routes │ ││ │ (dev tools) │ │ (users.ts, posts.ts) │ ││ │ │ │ │ ││ │ daemon-1 │ │ sqlite-1 ← database │ ││ │ (React dev server)│ │ (users, posts, KV) │ ││ │ │ │ │ ││ │ code-1 │ │ terminal-1 │ ││ │ (VS Code in │ │ (maintenance) │ ││ │ browser) │ │ │ ││ │ │ │ daemon-1 │ ││ │ display-1 │ │ (background jobs) │ ││ │ (live preview) │ │ │ ││ └────────┬───────────┘ └──────────┬──────────────┘ ││ │ │ ││ │ HTTP calls │ ││ └─────────────>─────────────┘ ││ ││ ┌──────────────────────────────────────────────────┐ ││ │ PROXY ALIASES │ ││ │ my-saas-app.<server>.containers.hoody.com │ ││ │ → frontend:http:3000 │ ││ │ api-my-saas-app.<server>.containers.hoody.com │ ││ │ → backend:exec-1 │ ││ └──────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────┐ ││ │ SNAPSHOTS │ ││ │ pre-launch-backend (entire backend state) │ ││ │ pre-launch-frontend (entire frontend state) │ ││ └──────────────────────────────────────────────────┘ │└──────────────────────────────────────────────────────────┘Every box is a URL and every arrow is an HTTP request. Each component can be snapshotted, shared, and composed with the others.
Development workflow
Section titled “Development workflow”With the app running, your daily development looks like this:
- Open hoody-code (VS Code in browser) to edit frontend or backend files
- Open hoody-terminal side by side for running tests, checking logs
- Open hoody-display for live preview of the React app
- Query hoody-sqlite directly via its web UI to inspect data
- Snapshot before any risky change; restore in seconds if something breaks
- Share the URL with your team; they are instantly in your development environment
There is no local setup, no “works on my machine,” and no environment drift: the development environment is the production environment, separated only by a proxy alias.
Ways to scale
Section titled “Ways to scale”When your app grows, the same architecture extends:
- Add more containers for microservices; each is just another URL
- Use SQLite Drive to share databases across containers via
/hoody/databases/ - Use Shared Storage for cross-container files via
/hoody/shares/ - Add hoody-cron for scheduled jobs (backups, cleanups, reports)
- Add hoody-browser for automated testing of your frontend
- Point hoody-agent at your codebase and let AI handle pull requests
The architecture is the same at 1 container or 100: every service takes HTTP in, sends HTTP out, and is addressed by a URL.
What’s Next
Section titled “What’s Next”- The Vibe Coding Revolution: let AI build your next feature while you watch
- Multiplayer by Default: share your development environment with your team
- Rapid Internal Tools: build admin dashboards and scripts in minutes
- Hoody Kit Overview: a closer look at the 18 services behind your app