Skip to main content
Alpha

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.

The Aspire AppHost resource graph: appsettings.json and plugin contributions feed createNetScriptAppHost, which registers infrastructure (Postgres, Redis), services, plugin APIs, and background processors, with cross-references resolved into injected environment variables, all observed by the dashboard at :18888.
One 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 APIworkers-api (:8091), sagas-api (:8092), triggers-api (:8093), auth-api (:8094), the durable-streams runtime (: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 / sqlite via --db) — and a shared cache — redis by default, or garnet / deno-kv via --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:

  1. It is TypeScript/Node, not C#. aspire.config.json declares language: "typescript/nodejs" and appHost.path: "apphost.mts". The AppHost runs on an isolated Node.js runtime inside aspire/ (with its own package.json and .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.
  2. The graph is derived, not hand-written. You do not edit apphost.mts to add a service. You declare infrastructure, services, plugins, and processors in appsettings.json, and each plugin contributes its own resources programmatically. createNetScriptAppHost reads 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:

AspireResource — what a plugin contribution returns
NameTypeDescription
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:

Reference fields on a resource entry (resolved at compose time)
NameTypeDescription
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.

The resource graph a single aspire start brings up
NameTypeDescription
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 by traceparent, 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 cloneaspire 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-aspire is 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-aspire plus 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 by aspire.config.json.
  • Contribution — a plugin's declaration of the resources it adds to the graph, expressed by extending AspireNSPluginContribution and returning AspireResource[] from contribute(...).
  • Resource — any node Aspire manages: a container (Postgres, Redis), a database, a cache, a deno-service, or a deno-background processor.
  • 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

Services , and

Background jobs are the hubs behind the graph nodes.