GUIDE
Docker Database Services
Deploy PostgreSQL + PostgREST in Docker to give your phone apps a relational database with automatic REST API. Zero server code, per-app isolation.
WHY
Why Docker Database Services
Phone apps with local-only storage hit a ceiling. When you need relational data, full-text search, or shared state across sessions, you need a real database.
Zero API Code
PostgREST auto-generates a full REST API from your PostgreSQL tables. Define a schema, get CRUD endpoints instantly.
Per-App Isolation
Each app gets its own PostgreSQL schema. Apps cannot read or write data from other apps.
Instant CRUD
Filtering, sorting, pagination, and full-text search — all via URL query parameters, no code needed.
Localhost Only
Docker ports are bound to 127.0.0.1. Database credentials never leave your Mac. Phone apps access data through the bridge proxy.
Architecture
Phone apps talk to the database through a secure proxy chain. No direct database connections from the phone.
fetch('/api/v1/gateway', {route: 'services/proxy', ...})
Port 8435 — existing gateway handler
handleServiceProxy() — looks up service, sets schema headers
localhost:3100 — auto-generated REST API
Docker-internal only — per-app schemas
Why PostgREST instead of a custom API server?
- • Zero server code — PostgREST auto-generates REST endpoints from your tables
- • Battle-tested — powers Supabase, handles millions of requests
- • Schema routing — per-app isolation via PostgreSQL schemas + Accept-Profile header
- • Rich queries — filtering, sorting, pagination, full-text search via URL params
SETUP
Get started in 5 steps
Install Docker Desktop
Download and install Docker Desktop from docker.com. Make sure the Docker daemon is running (whale icon in menu bar).
Enable Docker Services in SPEC4AI Link
Open SPEC4AI Link → Settings → Docker Services → Enable. This tells the bridge to start the PostgreSQL + PostgREST stack automatically.
Verify the stack is running
After enabling, the bridge will automatically run docker compose up. Verify both containers are healthy:
docker ps --filter "name=spec4ai"
# Should show:
# spec4ai-postgres (healthy)
# spec4ai-api (running)
# Test PostgREST directly:
curl http://127.0.0.1:3100/
# Should return: {"info":"..."}Add services to your app spec
In your app's spec.json, declare the services it needs and the SQL migrations to set up its schema:
{
"id": "custom.inventory_tracker",
"name": "Inventory Tracker",
"services": {
"api": {
"type": "postgrest",
"migrations": [
"CREATE SCHEMA IF NOT EXISTS app_inventory_tracker;",
"CREATE TABLE IF NOT EXISTS app_inventory_tracker.items (id serial primary key, name text not null, quantity int default 0, created_at timestamptz default now());",
"GRANT USAGE ON SCHEMA app_inventory_tracker TO web_anon;",
"GRANT ALL ON ALL TABLES IN SCHEMA app_inventory_tracker TO web_anon;",
"GRANT USAGE ON ALL SEQUENCES IN SCHEMA app_inventory_tracker TO web_anon;"
]
}
}
}Use the database from your app
Your app's JavaScript can now call the database through the bridge proxy. No API keys or credentials needed — the bridge handles authentication and schema routing automatically.
// Helper function for database calls
async function db(method, path, body) {
const r = await fetch('/api/v1/gateway', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
route: 'services/proxy',
params: {
service: 'api',
method,
path,
body,
app_id: 'custom.inventory_tracker'
}
})
});
const data = await r.json();
return data.result;
}
// CRUD operations via PostgREST query syntax:
const items = await db('GET', '/items?order=created_at.desc');
await db('POST', '/items', { name: 'Widget', quantity: 10 });
await db('PATCH', '/items?id=eq.5', { quantity: 15 });
await db('DELETE', '/items?id=eq.5');
// Filtering:
const low = await db('GET', '/items?quantity=lt.5');
const search = await db('GET', '/items?name=ilike.*widget*');REFERENCE
PostgREST Query Syntax
PostgREST uses URL query parameters for all operations. No SQL from the client side.
/items?status=eq.activeExact match/items?status=neq.archivedExclude values/items?quantity=gt.10Numeric comparison/items?price=lt.50Numeric comparison/items?name=ilike.*widget*Case-insensitive LIKE/items?status=in.(active,pending)Match any of multiple values/items?order=created_at.descSort results/items?limit=10&offset=20Pagination/items?select=id,name,quantityReturn only specific columnsPOST /items + JSON bodyCreate new rowPATCH /items?id=eq.5 + JSON bodyUpdate matching rowsDELETE /items?id=eq.5Delete matching rowsFull PostgREST documentation
PostgREST supports many more operators including JSON queries, range headers for pagination, and computed columns. See the official PostgREST API docs for the complete reference.
IMPORTANT
Writing Migrations Correctly
Migrations run every time your app is deployed. Follow these rules to avoid errors.
Always use IF NOT EXISTS
Migrations run on every app deploy. Make them idempotent so they don't fail on re-runs.
CREATE TABLE IF NOT EXISTS app_myapp.items (...);Always grant permissions
PostgREST uses the web_anon role. Without grants, your API returns empty results.
GRANT USAGE ON SCHEMA app_myapp TO web_anon;
GRANT ALL ON ALL TABLES IN SCHEMA app_myapp TO web_anon;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app_myapp TO web_anon;Use the app_ prefix for schemas
The bridge auto-converts your app ID to a schema name: custom.my_app → app_my_app. Always use this convention.
CREATE SCHEMA IF NOT EXISTS app_my_app;Add columns with ALTER TABLE for schema evolution
To add new columns after the initial release, use ALTER TABLE with IF NOT EXISTS or a DO block.
DO $$ BEGIN
ALTER TABLE app_myapp.items
ADD COLUMN category text DEFAULT 'general';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;DEEP DIVE
Per-App Schema Isolation
Every app gets its own PostgreSQL schema, preventing cross-app data access.
How schema routing works
- 1. Your app sends
app_id: "custom.inventory_tracker"in the proxy request - 2. The bridge converts this to schema name:
app_inventory_tracker - 3. Bridge sets
Accept-Profile: app_inventory_trackerandContent-Profile: app_inventory_trackerheaders - 4. PostgREST routes the query to only that schema's tables
Schema naming convention
SECURITY
Security model
- Docker ports bound to 127.0.0.1 — not accessible from the network
- PostgreSQL credentials stay on the Mac — never sent to the phone
- Per-app schema isolation — apps can only access their own data
- Bridge proxy adds schema headers automatically — apps cannot override isolation
- Migrations run at deploy time — not at runtime from the WebView
- All database calls flow through MCP → bridge proxy — full audit trail
TROUBLESHOOTING
Common issues
PostgREST returns empty array for a table that has data
Missing GRANT. Run: GRANT ALL ON ALL TABLES IN SCHEMA app_yourapp TO web_anon; and GRANT USAGE ON SCHEMA app_yourapp TO web_anon;
"relation does not exist" error
Make sure your Accept-Profile header matches your schema name. The bridge sets this automatically from app_id — check that app_id in your proxy call matches your migration schema.
Docker containers won't start
Check if Docker Desktop is running: docker info. If port 3100 is taken, another PostgREST instance may be running. Use docker ps to check.
Migrations fail with permission errors
The PostgreSQL user is spec4ai (superuser for the spec4ai database). Migrations should work. If using a custom role, make sure it has CREATE privileges.
Can't connect from phone app
Phone apps never connect to Docker directly. They go through: fetch → phone MCP → bridge → PostgREST. Make sure the bridge is running and connected to the phone.
Data persists between container restarts but not after docker compose down -v
The -v flag removes volumes, wiping all data. Use docker compose down (without -v) to stop services but keep data. Use docker compose down -v only to reset everything.
FULL EXAMPLE
Complete app with database
Here's a complete inventory tracker app showing the full spec.json services section and JavaScript CRUD code.
spec.json — services section
{
"id": "custom.inventory_tracker",
"name": "Inventory Tracker",
"description": "Track inventory items with quantities and categories",
"services": {
"api": {
"type": "postgrest",
"migrations": [
"CREATE SCHEMA IF NOT EXISTS app_inventory_tracker;",
"CREATE TABLE IF NOT EXISTS app_inventory_tracker.items (
id serial PRIMARY KEY,
name text NOT NULL,
category text DEFAULT 'general',
quantity integer DEFAULT 0,
unit_price numeric(10,2),
notes text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);",
"GRANT USAGE ON SCHEMA app_inventory_tracker TO web_anon;",
"GRANT ALL ON ALL TABLES IN SCHEMA app_inventory_tracker TO web_anon;",
"GRANT USAGE ON ALL SEQUENCES IN SCHEMA app_inventory_tracker TO web_anon;"
]
}
}
}JavaScript — database helper + CRUD
const APP_ID = 'custom.inventory_tracker';
async function db(method, path, body) {
const r = await fetch('/api/v1/gateway', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
route: 'services/proxy',
params: { service: 'api', method, path, body, app_id: APP_ID }
})
});
const data = await r.json();
if (!data.ok) throw new Error(data.error?.message || 'DB error');
return data.result;
}
// Load all items sorted by newest first
async function loadItems() {
return await db('GET', '/items?order=created_at.desc');
}
// Add a new item
async function addItem(name, category, quantity) {
return await db('POST', '/items', { name, category, quantity });
}
// Update quantity
async function updateQuantity(id, newQty) {
return await db('PATCH', '/items?id=eq.' + id, {
quantity: newQty,
updated_at: new Date().toISOString()
});
}
// Delete an item
async function deleteItem(id) {
return await db('DELETE', '/items?id=eq.' + id);
}
// Search by name
async function searchItems(query) {
return await db('GET', '/items?name=ilike.*' +
encodeURIComponent(query) + '*');
}
// Filter by category
async function getByCategory(cat) {
return await db('GET', '/items?category=eq.' +
encodeURIComponent(cat) + '&order=name');
}AI ASSISTANCE
Claude Code Helper Prompt
Copy this prompt into Claude Code when building apps with database services. It contains all the context Claude needs to implement correctly.
Prompt for Claude Code / Claude Desktop
Copy this entire blockYou are helping build a SPEC4AI Studio phone app that uses Docker database
services. Here is the complete technical context:
## Architecture
- Phone WebView apps call fetch('/api/v1/gateway') with route 'services/proxy'
- The phone's MCP server forwards this to the SPEC4AI bridge on the Mac
- The bridge's handleServiceProxy() looks up the service in its registry
- For PostgREST services, it sets Accept-Profile and Content-Profile headers
for per-app schema isolation, then forwards to http://127.0.0.1:3100
- PostgREST auto-generates REST API from PostgreSQL tables
## Database Helper Function (REQUIRED in every app using DB)
```javascript
const APP_ID = 'custom.YOUR_APP_NAME'; // Must match spec.json id
async function db(method, path, body) {
const r = await fetch('/api/v1/gateway', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
route: 'services/proxy',
params: { service: 'api', method, path, body, app_id: APP_ID }
})
});
const data = await r.json();
if (!data.ok) throw new Error(data.error?.message || 'DB error');
return data.result;
}
```
## PostgREST Query Syntax
- GET /table?column=eq.value — filter rows
- GET /table?order=col.desc — sort
- GET /table?limit=10&offset=20 — paginate
- GET /table?col=ilike.*term* — search (case-insensitive)
- GET /table?col=gt.10 — greater than
- GET /table?col=in.(a,b,c) — in list
- GET /table?select=id,name — select columns
- POST /table + JSON body — insert row
- PATCH /table?id=eq.5 + JSON body — update matching rows
- DELETE /table?id=eq.5 — delete matching rows
## Migration Rules (in spec.json services.api.migrations)
1. Always use CREATE SCHEMA IF NOT EXISTS app_<name>;
2. Always use CREATE TABLE IF NOT EXISTS
3. Schema name = "app_" + app_id after last dot (e.g., custom.foo → app_foo)
4. MUST grant permissions after creating tables:
GRANT USAGE ON SCHEMA app_<name> TO web_anon;
GRANT ALL ON ALL TABLES IN SCHEMA app_<name> TO web_anon;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app_<name> TO web_anon;
5. For schema evolution, use ALTER TABLE with exception handling:
DO $$ BEGIN
ALTER TABLE app_<name>.table ADD COLUMN col type DEFAULT val;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
6. Migrations must be idempotent — they run on every deploy
## Common Mistakes to Avoid
- NEVER connect to PostgreSQL directly from phone JavaScript
- NEVER hardcode localhost:3100 in phone app code — always use the gateway
- NEVER forget GRANT statements — PostgREST returns empty without them
- NEVER use schema names that don't start with "app_"
- ALWAYS include app_id in proxy requests — without it, schema isolation breaks
- ALWAYS use IF NOT EXISTS in migrations — they run repeatedly
## spec.json Services Section Template
```json
{
"services": {
"api": {
"type": "postgrest",
"migrations": [
"CREATE SCHEMA IF NOT EXISTS app_YOURAPP;",
"CREATE TABLE IF NOT EXISTS app_YOURAPP.tablename (...);",
"GRANT USAGE ON SCHEMA app_YOURAPP TO web_anon;",
"GRANT ALL ON ALL TABLES IN SCHEMA app_YOURAPP TO web_anon;",
"GRANT USAGE ON ALL SEQUENCES IN SCHEMA app_YOURAPP TO web_anon;"
]
}
}
}
```Ready to add a database to your app?
Get SPEC4AI Link, enable Docker Services, and start building data-driven apps today.