# 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.](https://rickylabs.github.io/netscript/netscript/assets/diagrams/aspire-resource-graph.svg)

*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`) on its assigned port.
- A **Fresh** dashboard app.
- Each runtime plugin's **HTTP API** — `workers-api`, `sagas-api`, `triggers-api`, `auth-api` (on their assigned high-range ports), the durable-`streams` runtime (on its assigned port).
- 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 recommended engine; 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.

> The key insight
>
> The unit of a NetScript app is the
>
> resource graph
>
> , not the process. Each plugin brings an HTTP service
>
> and
>
> its own isolated background workers
>
> and
>
> its infrastructure needs.
>
> Aspire
>
> lets you declare that graph once and boot it as a coherent whole, with the wiring between nodes already resolved — that is what an orchestrator buys you that a pile of
>
> deno task
>
> scripts never will.

**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.

> Aspire is step 2 — before any database command
>
> The canonical workflow is
>
> scaffold → orchestrate → database
>
> . After
>
> netscript init
>
> , you run
>
> cd aspire && aspire restore
>
> once, then
>
> aspire start
>
> . That command provisions your database (Postgres is the recommended engine, or
>
> mysql
>
> /
>
> mssql
>
> /
>
> sqlite
>
> via
>
> --db
>
> at
>
> init
>
> ) and Redis through Docker and brings up every service.
>
> Only then
>
> do
>
> netscript db init
>
> ,
>
> db generate
>
> , and
>
> db seed
>
> work — because those commands provision and migrate the database
>
> through
>
> the running AppHost. Run a db command with no Aspire up and it fails: there is no Postgres for it to talk to. See
>
> Database
>
> and the [CLI reference](https://rickylabs.github.io/netscript/netscript/cli-reference/) for the full sequence.

## 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.

```ts
// 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();
```

```json
{
  "appHost": { "path": "apphost.mts", "language": "typescript/nodejs" },
  "sdk": { "version": "13.4.6" },
  "profiles": {
    "https": {
      "applicationUrl": "https://localhost:0;http://localhost:0",
      "environmentVariables": {
        "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:0",
        "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
        "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true",
        "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:0"
      }
    }
  },
  "packages": {
    "Aspire.Hosting.PostgreSQL": "13.4.6",
    "Aspire.Hosting.Browsers": "13.4.6-preview.1.26319.6"
  }
}
```

> Dashboard and OTLP ports are ephemeral, not pinned
>
> The generated
>
> https
>
> profile asks Kestrel for
>
> ephemeral ports
>
> —
>
> applicationUrl
>
> , the OTLP endpoint, and the resource-service endpoint are all
>
> localhost:0
>
> , so Aspire binds a free port per run instead of hard-pinning the dashboard to
>
> :18888
>
> or the collector to
>
> :4318
>
> . Two consequences: (1)
>
> trust the URL `aspire start` prints
>
> , not a memorized port — the examples in this essay use
>
> :18888
>
> /
>
> :4318
>
> as the conventional Aspire defaults, but your run may differ; (2)
>
> aspire start --isolated
>
> gets its own free infra ports
>
> and
>
> isolated secrets under an isolated run folder, so several apphosts can run side by side without colliding on the base dashboard. Point telemetry backends at the endpoint the run reports, or set
>
> ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL
>
> yourself to fix it.

Two facts about the AppHost are worth internalizing because they contradict assumptions people carry from .NET Aspire:

1. **It uses an isolated TypeScript/Node runtime.** `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 deliberately 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:

```ts
// plugins/<name>/src/aspire-contribution.ts (conceptual shape)
import { AspireNSPluginContribution } from "@netscript/aspire/public";
import type { AspireBuilder, AspireResource, ContributionContext } from "@netscript/aspire/application";

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:

```ts
// How the AppHost composes plugin contributions into a builder
import { AspireTypeScriptBuilder } from "@netscript/aspire/adapters";
import {
  composeAppHost,
  type AspireBuilder,
  type AspireResource,
  type ComposePluginManifest,
  type ContributionContext,
} from "@netscript/aspire/application";
import { AspireNSPluginContribution } from "@netscript/aspire/public";

class WorkersContribution extends AspireNSPluginContribution {
  readonly pluginName = "@netscript/plugin-workers";

  contribute(_builder: AspireBuilder, _ctx: ContributionContext): readonly AspireResource[] {
    return [{ name: "workers-service", kind: "deno-service", port: 8080 }];
  }
}

const builder = new AspireTypeScriptBuilder();
const context: ContributionContext = {
  projectRoot: Deno.cwd(),
  port: (_key, fallback = 8080) => fallback,
  env: (source) => (typeof source === "string" ? source : source.kind === "literal" ? source.value : "configured"),
  resource: () => undefined,
  manifest: { name: "apphost" },
};
const plugins: readonly ComposePluginManifest[] = [
  {
    name: "@netscript/plugin-workers",
    contributions: { aspire: WorkersContribution },
  },
];

const { resources, registry } = composeAppHost({
  builder,
  context,
  plugins,
});
// `registry` is a ContributionRegistry keyed by pluginName; a duplicate
// plugin name throws DuplicateContributionError — the graph is dedup-checked.
```

Use `@netscript/aspire/application` when authoring focused composition modules. When an application or plugin prefers a single entrypoint for all configuration, composition, type, and testing exports, `@netscript/aspire/public` is the supported production aggregate export (`import { composeAppHost } from "@netscript/aspire/public"`). Note that the root export `@netscript/aspire` is reserved strictly for diagnostics (`inspectAspire`).

### Editor schema validation workflow

To enable editor validation and autocompletion for `appsettings.json`, `@netscript/aspire/schema` (also re-exported by `@netscript/aspire/public`) provides `generateAppSettingsJsonSchema`:

```ts
import { generateAppSettingsJsonSchema } from "@netscript/aspire/schema";

// Generates a draft-7 JSON Schema derived directly from Zod AppSettingsSchema
const jsonSchema = generateAppSettingsJsonSchema();
await Deno.writeTextFile("./.vscode/appsettings.schema.json", JSON.stringify(jsonSchema, null, 2));
```

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**

| 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.

> Where this connects
>
> This is the orchestration face of the plugin model. The same plugin that registers routes, a schema slice, and background work (see
>
> the plugin system
>
> ) also declares how it appears in the resource graph. One install, one removal — the whole stack, infrastructure included, follows.

## 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)**

| 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). |

> Services now declare references too
>
> Every resource entry —
>
> ServiceEntry
>
> included — extends the same
>
> ReferenceEntry
>
> , so a
>
> service
>
> is no longer excluded from plugin-reference wiring: it can carry
>
> PluginReferences
>
> and reach a plugin API over the injected
>
> services__<plugin>__http__0
>
> variable, exactly as apps and background processors do. You express this in
>
> netscript.config.ts
>
> on the service's own section, where two fields resolve into the generated
>
> appsettings.json
>
> entry:
>
> - `pluginReferences: string[]` — plugin APIs this service calls (lowers to `PluginReferences`). This is the field that closes the earlier "a Service can't declare `PluginReferences`" gap.
> - `dependsOn: string[]` — sibling services this service waits on (lowers to the resource's `ServiceReferences`, driving both discovery wiring and start ordering).
>
> See
>
> Discover services
>
> for the two-pass resolution these fields feed, and
>
> the config reference
>
> for the full service-section schema.

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.

```text
                          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 (service)
                              └─ redis     (Container, cache)
                                 │
                                 ▼  pass 2: resolve references → inject env
    ┌──────────────────────────────────────────────────────────────────────────────┐
    │ (5) plugin APIs  workers-api        sagas-api        triggers-api               │
    │     auth-api         streams (port)                                             │
    │ (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](https://rickylabs.github.io/netscript/netscript/reference/aspire/) — treat that as canonical and this essay as the orientation.

**The resource graph a single `aspire start` brings up**

| Name | Type | Description |
| --- | --- | --- |
| `aspire (dashboard)` | `https://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. The recommended engine is Postgres (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` | `assigned port` | Example oRPC service (defineService). Routes /api/v1/users/* and the RPC surface at /api/rpc/*. |
| `workers-api` | `assigned port` | Workers plugin API. /api/v1/workers/{jobs,executions,tasks,seed}; trigger via POST /api/v1/workers/jobs/{id}/trigger. |
| `sagas-api` | `assigned port` | Sagas plugin API. /api/v1/sagas/{sagas,instances,publish} plus liveness at /health/live. |
| `triggers-api` | `assigned port` | Triggers plugin API (raw Hono, not oRPC). POST /api/v1/webhooks/inbound/generic, GET /api/v1/events. |
| `auth-api` | `assigned port` | Auth plugin oRPC service. /api/v1/auth/{signin,callback,signout,session,me} with one active backend (NETSCRIPT_AUTH_BACKEND). |
| `streams` | `assigned port` | 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](https://rickylabs.github.io/netscript/netscript/services-sdk/services/) ,

[Background jobs](https://rickylabs.github.io/netscript/netscript/background-processing/workers/) , and

[Database](https://rickylabs.github.io/netscript/netscript/data-persistence/database/) are the practical pages behind the graph nodes above.

## Customizing the generated AppHost: restart, regenerate, or hand-edit

Once the graph is running, the recurring question is *which change needs which action*. Because the `register-*.mts` helper layer is **generated from `netscript.config.ts`/`appsettings.json`**, most edits are a config change plus a regenerate, not a hand-edit of the AppHost. The regenerate command is `netscript service generate` — it rewrites only the Aspire helper files, never your service code.

**Which change needs which action**

| Name | Type | Description |
| --- | --- | --- |
| `Handler / service / app code` | `hot or restart resource` | Apps and background processors scaffold with Deno watch mode, so they reload on save. A plain service restarts from the dashboard. No regenerate, no AppHost restart. |
| `Env / Permissions / Port / Workdir on a resource` | `generate + restart AppHost` | Edit the resource's section in netscript.config.ts, run `netscript service generate` to rewrite the register-*.mts layer, then restart `aspire start`. |
| `A reference: ServiceReferences / pluginReferences / dependsOn` | `generate + restart AppHost` | Same flow. Pass 2 only re-resolves and re-injects services____http__0 on the next `aspire start` — a declared-but-not-generated reference does nothing. |
| `Add / remove a plugin, or a new appsettings resource` | `generate + restart AppHost` | `netscript plugin install`/`remove` (or a hand-added resource) changes the graph; the resource only joins on the next generate + `aspire start`. |
| `An injected env value Aspire owns (OTEL_*, a connection string)` | `restart the resource` | Environment is read once at process start, so bounce the affected resource for the new value to take effect. |

> The register-*.mts files are regenerated — express config, don't hand-edit
>
> The scaffolded
>
> register-infrastructure.mts
>
> ,
>
> register-services.mts
>
> ,
>
> register-plugins.mts
>
> ,
>
> register-background.mts
>
> ,
>
> register-apps.mts
>
> , and
>
> register-tools.mts
>
> carry a generated header and are
>
> overwritten by the next `netscript service generate`
>
> . Anything you can express as config survives regen: per resource,
>
> Env
>
> ,
>
> Permissions
>
> ,
>
> Port
>
> , and
>
> Workdir
>
> are all declarable in
>
> netscript.config.ts
>
> /
>
> appsettings.json
>
> — reach for those first. When a wire genuinely is not config-expressible yet (an MCP-over-HTTP endpoint, a bespoke cross-resource reference), there is
>
> no automatic preserve mechanism today
>
> : tag the change
>
> // HAND-EDITED (keep on regen)
>
> and re-apply it after each generate. Prefer moving the need into config so the hand-edit disappears.

### Native database drivers need launch permissions

A resource's permission flags are set **at launch**, and they are separate from the per-task permissions you tighten *inside* a worker (those are covered in

[Restrict worker task permissions](https://rickylabs.github.io/netscript/netscript/background-processing/how-to/restrict-worker-task-permissions/) ). This matters when a plugin loads a **native (FFI) database driver** — for example the native libSQL/Turso client the workers runtime can use for per-channel storage. Without FFI access the process crashes at import with `NotCapable: Requires ffi access`. The workers **background processor** therefore launches with an explicit permission set that includes `--allow-ffi` (and `--allow-sys`):

```ts
// plugins/workers/src/aspire/workers-contribution.ts — the background launch set
const WORKERS_BACKGROUND_PERMISSIONS = [
  '--unstable-kv',
  '--allow-net',
  '--allow-env',
  '--allow-read',
  '--allow-write',
  '--allow-run',
  '--allow-sys',
  '--allow-ffi', // native FFI drivers (e.g. libSQL/Turso) load here
] as const;
```

If you author a plugin or service that pulls in a native driver, give its resource the matching `--allow-ffi` (and any `--allow-sys`) flag through its contribution's launch permissions — a missing flag surfaces only at runtime as an FFI capability crash, not at generate time.

## 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 `https://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](https://rickylabs.github.io/netscript/netscript/explanation/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`.

Inside a handler, the scaffold's `createJobTools(ctx)` helpers add events, progress, and child spans to the active job trace. See

[Observability](https://rickylabs.github.io/netscript/netscript/explanation/observability/) for the full framework-and-handler trace path.

## HTTP/2 and TLS: opt-in today

NetScript service listeners serve **plaintext HTTP/1.1 by default**, and that default is unchanged. HTTP/2 is available but strictly **opt-in via TLS**: `Deno.serve` negotiates h2 over ALPN the moment the listener has a certificate and key, so the way to get HTTP/2 is to hand the service TLS material. There are two ways to do that, and both flip the listener banner from `http` to `https`:

```ts
// Pass cert/key PEM material as ServeOptions.tls. Deno then negotiates HTTP/2
// over ALPN for clients that support it (HTTP/1.1 stays available as fallback).
import { createService } from '@netscript/service';
import type { ServiceTlsOptions } from '@netscript/service';

const tls: ServiceTlsOptions = {
  cert: await Deno.readTextFile('cert.pem'), // PEM certificate chain
  key: await Deno.readTextFile('key.pem'),   // PEM private key
};

await createService(router, { name: 'users' })
  .withHealth()
  .serve({ port: 3001, tls }); // note: your scaffold's port will differ
```

```bash
# When ServeOptions.tls is absent, the listener falls back to this env pair.
# BOTH must be set — one alone is ignored and the service stays plaintext.
NETSCRIPT_TLS_CERT_FILE=cert.pem \
NETSCRIPT_TLS_KEY_FILE=key.pem \
  deno run --allow-net --allow-read --allow-env services/users/main.ts

# With both present the service serves HTTPS + HTTP/2 — confirmed with `curl --http2`.
```

> Plaintext HTTP/1.1 is still the default
>
> Opting into TLS does not change the default path: with no
>
> tls
>
> option and no
>
> NETSCRIPT_TLS_CERT_FILE
>
> /
>
> NETSCRIPT_TLS_KEY_FILE
>
> pair, the listener is byte-for-byte the same plain HTTP/1.1 server. That has one consequence worth knowing: browsers cap HTTP/1.1 at roughly
>
> six concurrent connections per origin
>
> , which SSE and durable streams can exhaust — the fix is to adopt HTTP/2 per service by enabling TLS as above. Automatic Aspire dev-certificate provisioning and a default flip to HTTP/2 are still on the roadmap, so today this remains a deliberate opt-in. See
>
> Durable streams
>
> for the connection-cap caveat and [the service reference](https://rickylabs.github.io/netscript/netscript/reference/service/)
>
> for `ServiceTlsOptions` and `ServeOptions.tls`.

## 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.

```bash
netscript init my-app --db postgres --service --service-name users --yes

# Step 2: orchestration brings up Postgres + Redis + every process.
cd aspire && aspire restore   # once
aspire start                    # dashboard at https://localhost:18888

# Step 3: database commands now work (provisioned through Aspire).
netscript db init --name init
```

```bash
# 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.
```

> What you lose when you opt out
>
> --no-aspire
>
> trades convenience for control. Without the orchestration layer there is
>
> no AppHost
>
> (
>
> aspire/apphost.mts
>
> is not generated),
>
> no automatic Postgres/Redis provisioning
>
> ,
>
> no dashboard
>
> at
>
> :18888
>
> , and
>
> no automatic cross-process wiring
>
> — you become responsible for handing every process its connection strings and its neighbours' endpoints by hand. The
>
> netscript db
>
> commands lose the AppHost they provision through, so the database workflow becomes your responsibility 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-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

- **Understand the surrounding model:** [Architecture](https://rickylabs.github.io/netscript/netscript/explanation/architecture/)

  for how the pieces fit, and [The plugin system](https://rickylabs.github.io/netscript/netscript/explanation/plugin-system/)

  for the contribution model that feeds the graph.
- **Do the practical work:** [Database](https://rickylabs.github.io/netscript/netscript/data-persistence/database/) ,

[Services](https://rickylabs.github.io/netscript/netscript/services-sdk/services/) , and

[Background jobs](https://rickylabs.github.io/netscript/netscript/background-processing/workers/) are the hubs behind the graph nodes.

- **Look up exact symbols and the full port map:** [the Aspire reference](https://rickylabs.github.io/netscript/netscript/reference/aspire/)

  and the [CLI reference](https://rickylabs.github.io/netscript/netscript/cli-reference/) .
- **Related:** [Observability](https://rickylabs.github.io/netscript/netscript/explanation/observability/) explains the spans and logs the dashboard at `:18888` collects.

[Observability](https://rickylabs.github.io/netscript/netscript/explanation/observability/) [How NetScript's path compares](https://rickylabs.github.io/netscript/netscript/explanation/compared/)
