Orchestration with Aspire
A NetScript app is never one process. This essay explains why it needs an orchestrator at
all, how the orchestrator's resource graph is generated from your plugins rather than
hand-wired, and what a single aspire start actually stands up — so you can reason about the
running system instead of treating it as a black box.
aspire start derives a coherent resource graph from your plugins, resolves the wiring between resources, and surfaces the whole thing in a dashboard.Why an orchestrator at all
Picture what "run my app locally" really means for a multi-plugin NetScript project. It is not a single server. It is a small fleet:
- An example oRPC service (
defineService) at:3001. - A Fresh dashboard app.
- Each runtime plugin's HTTP API —
workers-api(:8091),sagas-api(:8092),triggers-api(:8093),auth-api(:8094), the durable-streamsruntime (:4437). - Each plugin's isolated background processors — the workers and sagas runners, the triggers processor — running as separate executables, not threads inside the API.
- The infrastructure all of those depend on: a database — Postgres (the default; or
mysql/mssql/sqlitevia--db) — and a shared cache —redisby default, orgarnet/deno-kvvia--cache-backend.
Standing that up by hand is the integration tax: start each process in dependency order, hand each one the right connection strings, teach each one where its neighbours live, and tear it all down cleanly afterwards. Do it wrong and you get the classic distributed-dev failure modes — a service that races ahead of its database, a processor pointed at the wrong cache, a plugin that cannot find the sibling it calls. Removing exactly that tax is the whole reason the framework adopts an orchestrator.
Aspire is the conductor. You describe the desired graph; a single command stands the whole
thing up with the wiring resolved. The database URL the users service needs, the cache endpoint
the workers runtime needs, the cross-reference one plugin holds to another — Aspire computes them
and injects them as environment variables, so no process has to discover its neighbours at
runtime.
The AppHost: a generated TypeScript program
The heart of orchestration is the AppHost — a small TypeScript/Node program scaffolded into
the aspire/ subfolder at aspire/apphost.mts. It is the entry point aspire start executes. It
is deliberately tiny: it builds an Aspire builder, hands it your project's appsettings.json,
and runs the resulting graph.
// aspire/apphost.mts (generated by `netscript init`)
import { createBuilder } from './.aspire/modules/aspire.mjs';
import { createNetScriptAppHost } from './.helpers/index.mjs';
// 1. Build an Aspire builder (SDK modules restored by `aspire restore`).
const builder = await createBuilder();
// 2. Translate appsettings.json + plugin contributions into a resource
// graph: db, cache, services, plugin APIs, background processors, apps.
await createNetScriptAppHost(builder, '../appsettings.json');
// 3. Build the graph and run it (dashboard + every resource).
await builder.build().run();
{
"appHost": { "path": "apphost.mts", "language": "typescript/nodejs" },
"sdk": { "version": "13.4.6" },
"profiles": {
"https": {
"applicationUrl": "https://localhost:18888;http://localhost:18889",
"environmentVariables": {
"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:4318",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true"
}
}
},
"packages": { "Aspire.Hosting.PostgreSQL": "13.4.6" }
}
Two facts about the AppHost are worth internalizing because they contradict assumptions people carry from .NET Aspire:
- It is TypeScript/Node, not C#.
aspire.config.jsondeclareslanguage: "typescript/nodejs"andappHost.path: "apphost.mts". The AppHost runs on an isolated Node.js runtime insideaspire/(with its ownpackage.jsonand.aspire/SDK modules) precisely so that the Node dependency graph never leaks into the Deno workspace at the project root. You author NetScript in Deno; Aspire orchestrates it from a sealed-off Node corner. - The graph is derived, not hand-written. You do not edit
apphost.mtsto add a service. You declare infrastructure, services, plugins, and processors inappsettings.json, and each plugin contributes its own resources programmatically.createNetScriptAppHostreads the config, runs the contributions, and registers each resulting resource. The next section is the important one: it explains how that derivation works.
How the graph is generated from your plugins
This is the part that makes Aspire feel like part of the framework rather than a bolted-on tool:
you never enumerate processes by hand. A NetScript plugin can declare an Aspire
contribution, and the AppHost asks every installed plugin to contribute its own slice of the
graph. The contract lives in @netscript/aspire and is genuinely small.
A plugin's contribution extends AspireNSPluginContribution. It names itself and, given a builder
and a context, returns the resources it wants in the graph:
// plugins/<name>/src/aspire-contribution.ts (conceptual shape)
import { AspireNSPluginContribution } from "@netscript/aspire";
import type { AspireBuilder, AspireResource, ContributionContext } from "@netscript/aspire";
class WorkersContribution extends AspireNSPluginContribution {
readonly pluginName = "@netscript/plugin-workers";
// Push this plugin's API + background processor into the builder.
contribute(builder: AspireBuilder, ctx: ContributionContext): readonly AspireResource[] {
// ... register a deno-service (the API) and a deno-background (the processor)
return [/* AspireResource[] */];
}
// Optional: extra env this contribution needs, and health checks the
// plugin doctor will probe.
declareEnv(ctx: ContributionContext) { return {}; }
declareHealthChecks(ctx: ContributionContext) { return []; }
}
The host side composes those contributions. composeAppHost walks the plugin manifests, finds the
ones that declare an aspire contribution, instantiates each, and collects the resources:
// conceptual: how the AppHost asks plugins for their resources
import { composeAppHost } from "@netscript/aspire";
const { resources, registry } = composeAppHost({
builder, // the Aspire builder port
context, // ContributionContext (paths, ports, env)
plugins, // [{ name, contributions: { aspire: Contribution } }, ...]
});
// `registry` is a ContributionRegistry keyed by pluginName; a duplicate
// plugin name throws DuplicateContributionError — the graph is dedup-checked.
The shape of each node the contributions produce is deliberately narrow — every resource is one of a small, closed set of kinds:
| Name | Type | Description |
|---|---|---|
name |
string |
Resource name in the AppHost graph (the label you see in the dashboard). |
kind |
AspireResourceKind |
One of: deno-service, deno-background, container, database, cache. The closed set keeps the graph reasoned-about. |
port |
number? |
TCP port the resource exposes, when applicable (services and plugin APIs; containers and processors may omit it). |
metadata |
Record |
Adapter-specific extras the builder backend understands. |
So the AppHost is generated, but not in a "code-spitting templates" sense. It is generated in the
stronger sense that the graph is assembled at boot from the plugins you installed. Add the
sagas plugin and its sagas-api plus its supervisor processor appear; remove it and they vanish —
no edit to apphost.mts required. The scaffold writes a thin register-*.mts helper layer
(register-infrastructure.mts, register-services.mts, register-plugins.mts,
register-background.mts, register-apps.mts, register-tools.mts) so each resource class lands
through the same builder.addExecutable(...) path with permissions, working directory, HTTP
endpoint, and OTEL environment resolved from config — but the content of the graph comes from
your plugin set.
Service discovery: how resources find each other
Declaring resources is half the job; the other half is wiring them. A plugin API may need a sibling plugin's HTTP endpoint; a service may depend on another service; a processor may need the database and the cache. NetScript expresses those needs as references on the config entry, and Aspire resolves them into injected environment variables so each process starts with its neighbours' addresses already present.
The reference vocabulary is small and explicit. @netscript/aspire extracts three kinds of
dependency from each entry:
| Name | Type | Description |
|---|---|---|
ServiceReferences |
string[] |
Other services this resource calls. extractServiceReferences deduplicates repeated entries. |
PluginReferences |
string[] |
Plugin APIs this resource calls. extractPluginReferences returns them for endpoint wiring (e.g. workers-api → sagas-api). |
RequiresDb |
boolean |
Whether the resource needs the Postgres connection. extractDependencies normalizes it (default false). |
RequiresKv |
boolean |
Whether the resource needs the Redis/KV connection. Normalized the same way (default false). |
Because endpoints only exist once resources are created, the wiring happens in two passes:
first every resource is created, then a second pass resolves each reference against the now-known
graph and injects it. In the generated builder that resolution lands as the equivalent of
getEndpoint('http') plus withEnvironment(...) for cross-references, and the database/cache
connection strings for the RequiresDb/RequiresKv flags. By the time a process's entry point
runs, the URLs it needs are in Deno.env — it never has to "discover" anything at runtime, which
is exactly why there is no service-registry client in your handler code.
aspire start (from aspire/)
│
▼
┌──────────────────────────────────────────┐
│ createNetScriptAppHost(appsettings.json) │
│ + composeAppHost(plugin contributions) │
└──────────────────────────────────────────┘
│ pass 1: create every resource
┌─────────────────────────────┼─────────────────────────────────────┐
▼ ▼ ▼
(1) dashboard OTLP (2) infrastructure (4) services
:18888 + :4318 ├─ postgres (Container) └─ users :3001
└─ redis (Container, cache)
│
▼ pass 2: resolve references → inject env
┌──────────────────────────────────────────────────────────────────────────────┐
│ (5) plugin APIs workers-api :8091 sagas-api :8092 triggers-api :8093 │
│ auth-api :8094 streams :4437 │
│ (6) background processors: workers, sagas (bin/combined.ts); │
│ triggers (src/runtime/trigger-processor.ts) │
│ (7) apps: dashboard (Fresh) (8) tools │
└──────────────────────────────────────────────────────────────────────────────┘
The order is not arbitrary. Infrastructure comes up first because everything else depends on it; references are resolved only after the resources they point at exist. The full port map for every runtime resource is consolidated under the Aspire reference — treat that as canonical and this essay as the orientation.
| Name | Type | Description |
|---|---|---|
aspire (dashboard) |
http://localhost:18888 |
The Aspire dashboard. aspire start prints a login token. Live resource list, logs, structured traces, and the OTLP collector (:4318) surface here. |
postgres |
Container |
Provisioned via Docker. Engine Postgres by default (swap to mysql / mssql — also Containers — or file-backed sqlite, which has no container, via --db), persistent (DataPath .data/postgres). The database that netscript db commands target — reachable only once Aspire is up. |
redis |
Container (cache) |
Redis cache — the default --cache-backend; Redis-compatible. Backs KV/queue workloads for the runtime plugins. Swap to garnet (also a Container) or app-level deno-kv via --cache-backend. |
users |
:3001 |
Example oRPC service (defineService). Routes /api/v1/users/* and the RPC surface at /api/rpc/*. |
workers-api |
:8091 |
Workers plugin API. /api/v1/workers/{jobs,executions,tasks,seed}; trigger via POST /api/v1/workers/jobs/{id}/trigger. |
sagas-api |
:8092 |
Sagas plugin API. /api/v1/sagas/{sagas,instances,publish} plus liveness at /health/live. |
triggers-api |
:8093 |
Triggers plugin API (raw Hono, not oRPC). POST /api/v1/webhooks/inbound/generic, GET /api/v1/events. |
auth-api |
:8094 |
Auth plugin oRPC service. /api/v1/auth/{signin,callback,signout,session,me} with one active backend (NETSCRIPT_AUTH_BACKEND). |
streams |
:4437 |
Durable-streams producer runtime. Served as its own Aspire Deno service; workers/auth/sagas mirror execution state into it. |
workers / sagas / triggers |
background processors |
Separate from the APIs: workers and sagas run from bin/combined.ts; triggers from src/runtime/trigger-processor.ts. Declared under appsettings BackgroundProcessors. |
Each of these capabilities has its own hub: Services ,
Background jobs , and
Database are the practical pages behind the graph nodes above.
The dashboard: the local observability surface
When aspire start finishes booting, it prints a URL and a one-time login token for the dashboard
at http://localhost:18888. The dashboard is the single pane of glass over the running graph:
- Resources — every container and executable above, with status, endpoints, and environment.
- Console logs — stdout/stderr per resource, so a failing background processor is one click away rather than buried in a terminal.
- Structured logs and traces — aspire starts an OTLP collector at
http://localhost:4318, and the spans and structured logs your handlers emit land here, correlated bytraceparent, so a request that fans out across services is a single trace rather than scattered log lines.
This is where the orchestration story and the observability
story meet. Aspire already knows the topology — every resource and its OTEL environment — so it can
stitch telemetry into that topology for free. Concretely, each resource is started with its
OTEL_SERVICE_NAME and an OTEL_EXPORTER_OTLP_ENDPOINT pointed at the dashboard collector
(http://localhost:4318, http/protobuf), so job dispatch, job execution, scheduler runs, and
subprocess task continuation all emit real OpenTelemetry spans that surface here with no extra
wiring — the trace context propagates into worker subprocesses over W3C traceparent.
The --no-aspire escape hatch
Aspire is the default and the recommended local path, but it is not mandatory. The init command
takes a --no-aspire flag (netscript init my-app --no-aspire) that skips scaffolding the
orchestration layer entirely: no aspire/ folder, no AppHost to provision infrastructure, no
dashboard. You start the generated Deno processes directly and provide your own infrastructure
connection strings.
netscript init my-app --db postgres --service --service-name users --service-port 3001 --yes
# Step 2: orchestration brings up Postgres + Redis + every process.
cd aspire && aspire restore # once
aspire start # dashboard at http://localhost:18888
# Step 3: database commands now work (provisioned through Aspire).
netscript db init --name init
# Scaffold WITHOUT the orchestration layer. No aspire/ folder is created.
netscript init my-app --db postgres --no-aspire --yes
# There is no `aspire start`. Start processes yourself:
deno task --cwd apps/dashboard dev
# You now own infrastructure: bring your own Postgres + cache and supply
# connection strings to each process. `netscript db` has no AppHost to
# provision through, so manage migrations against your own database URL.
When opting out is the right call:
- A deploy target that does its own orchestration. Kubernetes, Nomad, a managed PaaS, or a Docker Compose file you already maintain — the platform owns process lifecycle and service discovery, so a second orchestrator on top is redundant. This is the primary production case.
- A constrained or air-gapped environment. No Docker daemon, or a policy against running the Node AppHost runtime. You run Deno processes directly against externally-managed infrastructure.
- A single-purpose slice. You only want the Fresh app, or one service, with no database or cache — the full graph would be overhead.
For local development of a full multi-plugin app, opting out is almost always the wrong trade: you would be hand-rebuilding exactly the wiring the contributions generate for free.
What Aspire does and does not cover for production
Choosing Aspire as the default is an opinion, and this essay states its boundaries.
Aspire is the local-development orchestration story. Its job is to make git clone →
aspire start produce a complete, observable, correctly-wired stack on one machine. It excels at
that. What it deliberately does not try to be:
- A production deployment system. The AppHost provisions Postgres and Redis as local Docker containers for dev convenience. It is not your production database, not your production cache, and not a cluster scheduler. In production you point processes at managed/clustered infrastructure and let your platform own lifecycle and scaling.
- A replacement for your orchestrator of record. The same resource model that is a gift on a
laptop is redundant under Kubernetes or a PaaS that already does discovery, health, and restarts.
That is precisely why
--no-aspireis a first-class exit, not an afterthought.
The remaining trade-offs of the default path:
- A second runtime in the tree. The AppHost is Node/TypeScript while your app is Deno — a deliberate isolation so the two dependency graphs never contaminate each other, at the cost of "what runtime is this?" having two answers in one repo.
- Docker is a hard dependency of the happy path. No Docker daemon means the default workflow
does not start — which is exactly when
--no-aspireplus your own infrastructure earns its place. - The wiring is implicit. The two-pass reference resolution is invisible at authoring time, so inspect generated environment variables and the resource graph when debugging service discovery.
Glossary
- AppHost — the program that defines and runs an Aspire resource graph. In NetScript it is the
generated TypeScript
aspire/apphost.mts, configured byaspire.config.json. - Contribution — a plugin's declaration of the resources it adds to the graph, expressed by
extending
AspireNSPluginContributionand returningAspireResource[]fromcontribute(...). - Resource — any node Aspire manages: a
container(Postgres, Redis), adatabase, acache, adeno-service, or adeno-backgroundprocessor. - OTLP — the OpenTelemetry protocol endpoint (
http://localhost:4318) aspire starts so the dashboard can collect the spans and structured logs your handlers emit.
Where to go next
-
Understand the surrounding model: Architecture
for how the pieces fit, and The plugin system
for the contribution model that feeds the graph.
-
Do the practical work: Database ,
Services , and
Background jobs are the hubs behind the graph nodes.
-
Look up exact symbols and the full port map: the Aspire reference
and the CLI reference .
-
Related: Observability explains the spans and logs the dashboard at
:18888collects.