# Aspire: the Inngest server in your AppHost

> Run the Inngest dev server or a self-hosted server as a container of an Aspire AppHost, link its UI in the dashboard, and give projects the settings the SDK reads, with Inngest.Aspire.Hosting.

Web page: https://damikun.github.io/inngest-dotnet/guides/aspire/

[Aspire](https://aspire.dev) starts the parts of an app on a developer machine, which are projects and containers, and
connects them. `Inngest.Aspire.Hosting` adds the Inngest server to that: the AppHost runs it as a container, links its
UI in the Aspire dashboard, gives the projects the settings the SDK reads to find it, and tells it where the apps
that serve functions are. There is no `docker run` and no port to copy between terminals.

It is for local development. It starts the dev server, or optionally a self-hosted server that signs its requests,
and nothing here deploys Inngest.

## Install

Add the package to the AppHost project, not to the apps:

```bash
dotnet add package Inngest.Aspire.Hosting
```

The package needs Aspire 13.6.4 or later. It depends on `Aspire.Hosting` 13.6.4, so an AppHost on an older 13.x is
raised to that version. It targets `net8.0` and `net10.0`, and it has no dependency on the `Inngest` package, because
the AppHost only has to know the names of the settings, not the SDK. It runs in the AppHost, which is never trimmed or
published as a native binary, so unlike the other packages it is not marked as trim or Native AOT compatible;
`Aspire.Hosting` is not either.

nuget.org reserves the `Aspire.` prefix for the Aspire team and its partners, so a community package cannot be called
`Aspire.Hosting.Inngest`; hence `Inngest.Aspire.Hosting`, like `CommunityToolkit.Aspire.Hosting.*`.

## Add the server

`AddInngest` adds the dev server. `WithReference` gives a project the settings that make its SDK use that server, and
`WithApp` tells the server to sync the project, which serves its functions with `MapInngest`:

```csharp title="AppHost/Program.cs"
var builder = DistributedApplication.CreateBuilder(args);

var inngest = builder.AddInngest("inngest");

var shop = builder.AddProject<Projects.Shop>("shop")
    .WithReference(inngest)
    .WaitFor(inngest);

inngest.WithApp(shop);

builder.Build().Run();
```

`WithApp` builds the address from the endpoint of the project that is named `http`. A project made from a template has
one from the `http` profile of its `launchSettings.json`. Without one the server fails to start with "The endpoint
`http` is not defined for the resource": give the project one with `WithHttpEndpoint(name: "http")`, or pass the name
of another endpoint to `WithApp`.

Run the AppHost and the dashboard shows two resources. `inngest` is healthy once the server answers `GET /health`
and its Connect gateway answers `GET /ready`, and its row links to the server's UI as **Inngest UI**. `shop` starts
once the server is healthy, because of `WaitFor`. The server then syncs `shop` and lists its functions in the UI;
sending an event runs them. The repository has such an AppHost for its sample app, in
[`samples/Inngest.Sample.AppHost`](https://github.com/damikun/inngest-dotnet/tree/main/samples/Inngest.Sample.AppHost).

What the three calls do:

- **`AddInngest`** runs the `inngest/inngest` image at the tag `v1.49.0`, the Inngest CLI version the tests of this SDK
  run against, as `inngest dev --no-discovery`. Discovery would only scan the container's own ports. It has two
  endpoints: `http`, which is the REST API, the event API and the UI, and `connect`, which is the gateway of
  [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) workers. Aspire proxies both on ports it picks. Change the tag with
  `WithImageTag`, and fix the ports with the `port` and `connectPort` arguments.
- **`WithReference`** sets the environment variables below on the project. It does not make the project wait for the
  server.
- **`WithApp`** adds the project's URL to the arguments of the server (`-u`). The server calls it to sync the
  functions, and again every 5 seconds, which is the dev server's polling interval, so a project that starts after the
  server, or restarts with other functions, is found. Until the first sync the server knows no function of the project,
  so an event that reaches it by then starts no run.

Do not make the server wait for the project with `WaitFor`: the project would wait for the server and the server for
the project.

## What a project gets

`WithReference(inngest)` sets these variables, which the SDK reads as described in
[Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md):

| Variable | Dev server | Self-hosted server |
| - | - | - |
| `INNGEST_DEV` | the address of the server, which turns on dev mode and points the SDK at it | `0` |
| `INNGEST_BASE_URL` | the address of the server | the address of the server |
| `INNGEST_API_BASE_URL` | the address of the server | the address of the server |
| `INNGEST_EVENT_API_BASE_URL` | the address of the server | the address of the server |
| `INNGEST_SIGNING_KEY` | | the signing key |
| `INNGEST_EVENT_KEY` | | the event key |
| `INNGEST_CONNECT_GATEWAY_URL` | the address of the gateway | the address of the gateway |

The addresses are worked out when the project starts, for that project. A project on the host gets
`http://localhost:<port>`, where the port is the one Aspire proxies the server on. A container gets the name of the
server on the container network. The same `WithReference` is right for both, and nothing in the app has to know about
Aspire: the app is an ordinary Inngest app that reads its settings from the environment.

`INNGEST_DEV` is set to the address in dev mode so that any SDK that reads the address from it, such as the TypeScript
one, finds the server too; `INNGEST_BASE_URL` has the same address for SDKs that read only that. The SDK reads
`INNGEST_API_BASE_URL` and `INNGEST_EVENT_API_BASE_URL` before `INNGEST_BASE_URL`, so they are set as well. A
self-hosted server sets `INNGEST_DEV` to `0` on purpose.

Projects inherit the environment of the AppHost, and the settings above win over the same variables in it, so an
`INNGEST_DEV` or an `INNGEST_API_BASE_URL` left in a shell does not send a project elsewhere or switch off the checks
of signatures. Every other `INNGEST_*` variable of that environment still reaches the projects, and some of them break
a run without saying so. An `INNGEST_SERVE_ORIGIN` makes the server call an address that is not the project's, so its
runs cannot be delivered. Unset such a variable before you start the AppHost, or replace it for one project with
`WithEnvironment`.

## How the server reaches an app

The server runs in a container, where `localhost` is the container itself. `WithApp` does not write an address; it
gives the server a reference to the endpoint of the project, and Aspire resolves that for the server's container when
it starts:

- A project that runs on the host is reached through the container tunnel that Aspire 13 starts, at an address like
  `aspire.dev.internal:<port>`. The tunnel does not depend on `host.docker.internal`, so the integration adds no
  `--add-host=host.docker.internal:host-gateway`, which the plain Docker command of
  [Getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md#4-run-it) needs on Linux. Where the tunnel is turned off,
  Aspire falls back to the host name of the container runtime, which is `host.docker.internal` on Docker Desktop. The
  tests of this repository have run on Docker Desktop for Mac only.
- An app that is itself a container is reached by its name on the container network.

The SDK registers the address that the server synced it through, so each step of a run calls the app at the same
address, and the server needs no other.

`WithApp` takes the path the app serves Inngest on and the name of its endpoint, `/api/inngest` and `http` by default:

```csharp
var inngest = builder.AddInngest("inngest");

var billing = builder.AddProject<Projects.Billing>("billing")
    .WithHttpEndpoint(name: "internal")
    .WithReference(inngest);

inngest.WithApp(billing, "/webhooks/inngest", endpointName: "internal");
```

Call it once for every app that serves functions. An app that only sends events needs no `WithApp`, and neither does
a Connect worker.

Use an `http` endpoint. An `https` endpoint of a project uses the ASP.NET Core development certificate, which the
container does not trust unless you set that up with Aspire's certificate trust settings.

## Connect workers

A [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) worker has no endpoint, so there is nothing to register with `WithApp`.
It only needs `WithReference`:

```csharp
builder.AddProject<Projects.Worker>("worker")
    .WithReference(inngest)
    .WaitFor(inngest);
```

The worker calls the API of the server to start a connection, and the server answers with the address of its gateway.
That address is made of the host the worker called and the port the gateway has inside the container, and Aspire
publishes the gateway on another one, so a worker that used it would dial a port that nothing on the host listens on.
That is why `WithReference` sets `INNGEST_CONNECT_GATEWAY_URL`, which the SDK dials instead. The address is that of
the `connect` endpoint, resolved for the worker like the others. A worker that is started by other means than this
reference gets the server's answer and has to set the variable itself.

## A self-hosted server

The dev server checks nothing: it does not sign its requests and accepts any event key. `AddInngestSelfHosted` runs
`inngest start` instead, which signs every request to an app with a signing key, wants the key on its REST API and
refuses events that do not carry its event key. A project that references it runs in cloud mode with the same keys, as
it would in production:

```csharp
var signingKey = builder.AddParameter("inngest-signing-key", secret: true);
var eventKey = builder.AddParameter("inngest-event-key", secret: true);

var inngest = builder.AddInngestSelfHosted("inngest", signingKey, eventKey);

var shop = builder.AddProject<Projects.Shop>("shop")
    .WithReference(inngest)
    .WaitFor(inngest);

inngest.WithApp(shop);
```

The keys are secret parameters, which Aspire reads from the user secrets or the configuration of the AppHost, or
asks for in the dashboard. The server takes them from `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY` in its environment,
so they are not in its command line. `inngest start` accepts only a signing key that is a hex string of even length,
without a `signkey-` prefix, such as the output of `openssl rand -hex 32`. The SDK accepts that key as it is. When the
server starts, the AppHost checks the keys and reports one that `inngest start` would refuse, instead of leaving you
with a container that stopped.

Leave the keys out and the integration generates them, as Aspire does for the passwords of other containers:

```csharp
var inngest = builder.AddInngestSelfHosted("inngest");
```

A generated signing key has 64 digits, which are valid hex, and a generated event key is letters and digits. Both are
kept in the user secrets of the AppHost, when it has them, as `Parameters:inngest-signing-key` and
`Parameters:inngest-event-key`, so that they stay the same between runs, and a value you put there wins.

Unlike the dev server, the self-hosted server polls for apps only when it is told to, so the integration starts it with
a polling interval of 5 seconds.

## Keeping the state

The dev server holds its runs and events in memory, and the self-hosted server writes a SQLite database, `main.db`, to
`/.inngest` inside its container. Both lose their state when the container is removed, because the integration adds
no volume. Inngest v1.49.0 can keep it: `inngest dev --persist` writes the dev server's database to the same
directory, and a volume at that directory survives the container. Pass the flag with `WithArgs` and add the volume:

```csharp
var inngest = builder.AddInngest("inngest")
    .WithArgs("--persist")
    .WithVolume("inngest-data", "/.inngest");
```

The self-hosted server always writes its database, so it needs only the volume. `inngest start` also takes
`--sqlite-dir <directory>` to move the database, and then the volume goes to that directory:

```csharp
var inngest = builder.AddInngestSelfHosted("inngest")
    .WithVolume("inngest-data", "/.inngest");
```

`WithLifetime(ContainerLifetime.Persistent)` keeps the container itself, memory and all, across runs of the AppHost. The
integration starts no Postgres or Redis for the self-hosted server; `inngest start` takes their addresses as
`--postgres-uri` and `--redis-uri`.

## In a manifest

`aspire publish` writes a manifest that deployment tools turn into a deployment. What it holds depends on the server:

- **The dev server is not in it.** It checks no signatures, so a deployment must not run it. `WithReference` to the dev
  server adds nothing to a manifest either, because `INNGEST_DEV` would put the deployed app into dev mode. The
  app deploys in cloud mode, with the keys and addresses that you give it where it is deployed.
- **The self-hosted server is in it**, as a container resource: the image and tag, the entrypoint and the arguments
  (`start --poll-interval 5` and a `-u` for each app, as an expression of the app's address), the `http` and `connect`
  endpoints, and the keys as the secret parameters `<name>-signing-key` and `<name>-event-key`, which the deployment
  asks for or generates. The projects that reference it get `INNGEST_DEV=0`, the addresses, the keys and the gateway
  address as references to that resource and those parameters.

The self-hosted server keeps its state inside its container, as it does on your machine, so a deployment that replaces
the container loses it, and the manifest has no volume or database for it. Treat the integration as a way to run
Inngest while you develop; [Deploying](https://damikun.github.io/inngest-dotnet/guides/deploying.md) covers production.

## What it does not do

- **It keeps no state beyond its container.** See [Keeping the state](https://damikun.github.io/inngest-dotnet/guides/aspire.md#keeping-the-state).
- **It does not deploy Inngest.** The manifest has the self-hosted server as described above, but Inngest in production
  is Inngest Cloud or a server you run and size yourself.
- **It cannot be called from an AppHost written in another language.** The methods are not exported for Aspire's
  polyglot AppHosts.

## Testing the AppHost

The integration works under `Aspire.Hosting.Testing`, which is how this repository tests it. Its tests start the dev
server, a small app and a Connect worker, wait for them to be healthy and run a function through the server, and do the
same against the self-hosted server. They need Docker. Other tests write the manifest of both servers and read it.
