Services & Environments¶
The core service model: services are project-scoped definitions, instantiated per environment.
A service exists in an environment iff a service_environments row exists — per-env presence,
version, branch, size, and placement are fully independent, while identity (service_id) stays
stable across environments, as §20.2/§38.2/FR-077 assume. This page owns the model's tables and
endpoints. (Design record: specs/2026-07-11-database-schema-design.md.)
api |
worker |
|
|---|---|---|
| staging | ✓ 1 replica · develop branch | ✓ |
| production | ✓ 3 replicas · main branch | — (not instantiated here) |
Tables¶
services (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- slug; drives hostname generation (§39.1 #8)
type TEXT NOT NULL CHECK (type IN ('web', 'worker', 'cron')), -- cron post-MVP (§39.3 #48)
source_type TEXT NOT NULL DEFAULT 'git' CHECK (source_type IN ('git', 'image')), -- git build vs prebuilt image (templates, §39.3)
image_ref TEXT, -- pinned image when source_type='image' (e.g. ghost:5); NULL for git
git_installation_id UUID REFERENCES github_installations(id),
git_repo TEXT, -- "owner/repo" (git source)
build_config JSONB NOT NULL DEFAULT '{}', -- root_directory · dockerfile_path · build/start/pre_deploy commands (§16.5)
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- soft delete: tombstone protocol (§25.1)
UNIQUE (project_id, name)
);
service_environments ( -- FR-077's "service-per-environment record"
id UUID PRIMARY KEY,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
cluster_id UUID REFERENCES clusters(id), -- placement (FR-077)
branch TEXT, -- branch→env mapping (§39.1 #17); NULL = manual deploys
auto_deploy BOOLEAN NOT NULL DEFAULT TRUE, -- #17 opt-out flag
current_deployment_id UUID, -- FK added with deployments (Build Lifecycle)
tier TEXT NOT NULL DEFAULT 'mininova', -- Nova ladder; resources derived at render (§24.2)
replicas INT NOT NULL DEFAULT 1,
autoscaling JSONB, -- {enabled,min,max,cpu,mem} → §25.1 block
public BOOLEAN NOT NULL DEFAULT TRUE, -- web only: false = internal-only, no HTTPRoute/DNS (§39.1 #10)
hostname TEXT UNIQUE, -- public URL <name>-<rand4>.starform.app (public web only); customer-editable (§39.1 #8)
internal_host TEXT, -- ClusterIP Service name <service-name>-<environment>; shown + auto-injected (§39.1 #10)
suspended BOOLEAN NOT NULL DEFAULT FALSE, -- billing suspension (§36.3)
health_check JSONB, -- {path?, port, timeout_seconds} → §20.3 probe mapping
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
deletion_confirmed_at TIMESTAMPTZ, -- Shuttle tombstone ack (§25.4)
UNIQUE (service_id, environment_id)
);
service_connections ( -- service-to-service wiring (§39.1 #10); same environment only
id UUID PRIMARY KEY,
from_service_environment_id UUID NOT NULL REFERENCES service_environments(id) ON DELETE CASCADE,
to_service_environment_id UUID NOT NULL REFERENCES service_environments(id) ON DELETE CASCADE,
env_var_key TEXT NOT NULL, -- injected into the from-service (default <TARGET_NAME>_URL)
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (from_service_environment_id, env_var_key) -- value = http://<to.internal_host>:<port>
);
Changing type or source_type
Both are set at creation. Changing a service's type (web/worker/cron) from the dashboard is a
delete-and-rebuild of the type-specific resources — web→worker drops the Service/HTTPRoute/DNS
record, worker→web adds them — which Shuttle already does by reconciling the full resource set;
service_id is preserved, so metrics and log history survive. public, hostname, and
scaling are ordinary live edits.
API endpoints¶
Conventions (auth, errors, pagination, Developer* notation) →
Dashboard API.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET / POST | /projects/{id}/services |
Viewer / Developer | List · create definition (name conflict → 409) |
| GET / PATCH / DELETE | /services/{id} |
Viewer / Developer / Proj Admin | Read · edit repo/build_config · delete (tombstone) |
| GET | /services/{id}/environments |
Viewer | The env matrix (where it runs, with what config) |
| PUT | /services/{id}/environments/{env_id} |
Developer* | Create/update the instantiation (payload below) |
| DELETE | /service-environments/{id} |
Developer* | Remove from this env (tombstone) |
| POST | /service-environments/{id}/restart |
Developer* | Zero-downtime rolling restart (§38.7 checksum) |
| GET / PUT / DELETE | /service-environments/{id}/connections |
Viewer / Developer* | List · set · remove service-to-service links (auto-injected, §39.1 #10) |
{
"cluster_id": null, // null = Starbase places it (project default region, FR-077)
"branch": "develop", // branch→env mapping (#17); null = manual deploys only
"auto_deploy": true,
"public": true, // web only: false = internal-only, no public URL (§39.1 #10)
"tier": "nova",
"replicas": 2,
"autoscaling": { "enabled": true, "min_replicas": 2, "max_replicas": 6, "target_cpu_percent": 75 },
"health_check": { "path": "/healthz", "port": 3000, "timeout_seconds": 300 }
}
Deploying into an instantiation (builds, deployments, rollback) → Build Lifecycle.
Networking & connections¶
Three exposure levels, from type + public:
| type · public | Serves HTTP | ClusterIP Service | Public URL + DNS | Reachable by |
|---|---|---|---|---|
web · public=true |
✓ | ✓ | ✓ (<name>-<rand4>.starform.app, §39.1 #8) |
the internet |
web · public=false |
✓ | ✓ | ✗ | same-project, same-env services |
worker / cron |
✗ | ✗ | ✗ | nobody (calls outbound only) |
- Internal address (shown to the customer). A web service-env's ClusterIP Service is named
<service-name>-<environment>(stored asinternal_host); siblings reach it athttp://<service-name>-<environment>:<port>— e.g.http://api-production:8080. The dashboard shows this shorthost:port, never theproj-<uuid>.svc.cluster.localFQDN (that stays under an "advanced" reveal). It resolves because same-project services share one namespace, and the §20.4 NetworkPolicy already scopes reachability to same project, same environment. Cross-region has no private path — use the public hostname (FR-078). Workers/cron have no internal address. - Connections (auto-inject). Linking a consumer → a target in the same environment
(
service_connections) makes Starbase injectenv_var_key(default<TARGET_NAME>_URL) =http://<target.internal_host>:<port>into the consumer's env, resolved per environment — the same path that injects DB credentials, so service and database wiring behave alike. The raw internal address stays available for anything a link doesn't cover; cross-service${{…}}reference variables remain deferred (a templating engine).
Cross-references
The full data-model map → Data Model · the environments table → RBAC §15.7 · what Shuttle renders per instantiation → §20.2 · the payload entry it becomes → §25.1 · hostname → region routing → DNS & Domains · probes & pre-deploy → §20.3 · suspension → Billing §36.3. Canonical map: Canonical Sources.