Initial scaffold: Elsa 3.5 custom API server with sample Shout activity

- src/Elsa.CustomServer: minimal ASP.NET Core 8 host referencing Elsa 3.5 NuGets
- Activities auto-registered via AddActivitiesFrom<Program>() — drop new files under Activities/
- Sqlite persistence for workflow definitions + runtime
- Multi-stage Dockerfile targeting linux/amd64, ASP.NET 8 runtime, port 8080
- Forwarded-headers configured so URLs come out https:// behind Traefik
- CORS wide-open so the standalone Studio (different origin) can talk to us
This commit is contained in:
Claude
2026-09-02 08:09:20 +00:00
commit 75d250b7f7
9 changed files with 277 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
**/bin
**/obj
**/App_Data
**/*.db
**/*.db-*
.git
.gitignore
.vscode
.idea
deploy.json
README.md

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
## .NET
bin/
obj/
*.user
*.suo
.vs/
## Rider / VS Code
.idea/
.vscode/
## Runtime / build output
publish/
out/
TestResults/
## macOS / Linux
.DS_Store
## Elsa runtime state
App_Data/
*.db
*.db-*
*.sqlite
*.sqlite-journal
## Coolify config carries the app UUID and env values, keep it local
deploy.json

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Elsa Custom Server — Docker image
# Multi-stage build so the final image only carries the ASP.NET runtime.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /source
# Restore first for better layer caching.
COPY src/Elsa.CustomServer/Elsa.CustomServer.csproj src/Elsa.CustomServer/
RUN dotnet restore src/Elsa.CustomServer/Elsa.CustomServer.csproj
# Then copy the rest and publish.
COPY src/ src/
RUN dotnet publish src/Elsa.CustomServer/Elsa.CustomServer.csproj \
-c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish ./
# Ensure the SQLite DB and any workflow blobs land on a writable path we can volume-mount.
RUN mkdir -p /app/App_Data && chmod 777 /app/App_Data
VOLUME ["/app/App_Data"]
# Kestrel binds to whatever HTTP_PORTS says; keep the container port fixed so Coolify
# can wire Traefik consistently.
ENV ASPNETCORE_URLS=http://+:8080
ENV HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Elsa.CustomServer.dll"]

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# Elsa Custom Server
Custom Elsa Workflows API server. Extends stock Elsa 3.x with our own activities.
## Where activities live
`src/Elsa.CustomServer/Activities/*.cs` — anything marked `[Activity(...)]` under this
folder is auto-registered via `AddActivitiesFrom<Program>()` in `Program.cs`.
To add a new node type in the Studio designer:
1. Drop a new file under `Activities/`.
2. Inherit from `CodeActivity`, `CodeActivity<T>`, or `Activity` for triggerable ones.
3. Rebuild and redeploy — Studio picks it up automatically the next time it fetches
the activity descriptors.
Example — see `Activities/Shout.cs`.
## Local run
```sh
dotnet run --project src/Elsa.CustomServer
```
Listens on http://localhost:8080; API root is `/elsa/api`.
## Docker
```sh
docker build -t elsa-custom-server .
docker run --rm -p 8080:8080 \
-e Identity__Tokens__SigningKey="$(openssl rand -base64 64)" \
elsa-custom-server
```
## Deployment
Coolify builds this image from the Dockerfile at repo root when the app is created
with `build_pack: "dockerfile"`. The Studio (separate container running the stock
`elsaworkflows/elsa-studio-v3` image) needs its `BACKEND__URL` env var pointed at
this server's `/elsa/api`.

View File

@@ -0,0 +1,21 @@
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
namespace Elsa.CustomServer.Activities;
/// Sample custom activity — appears in the Studio toolbox under the "Custom" category.
[Activity("Custom", "Shout", "Uppercases a string and returns it.", Kind = ActivityKind.Task)]
public class Shout : CodeActivity<string>
{
[Input(Description = "The text to shout.")]
public Input<string> Text { get; set; } = default!;
protected override void Execute(ActivityExecutionContext context)
{
var input = context.Get(Text) ?? string.Empty;
var result = input.ToUpperInvariant() + "!";
context.SetResult(result);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Elsa.CustomServer</RootNamespace>
<AssemblyName>Elsa.CustomServer</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- Elsa 3.x meta packages. Kept on the latest 3.x line so the Studio (elsa-studio-v3) can talk to us. -->
<PackageReference Include="Elsa" Version="3.5.0" />
<PackageReference Include="Elsa.Http" Version="3.5.0" />
<PackageReference Include="Elsa.Identity" Version="3.5.0" />
<PackageReference Include="Elsa.Scheduling" Version="3.5.0" />
<PackageReference Include="Elsa.Workflows.Api" Version="3.5.0" />
<PackageReference Include="Elsa.EntityFrameworkCore" Version="3.5.0" />
<PackageReference Include="Elsa.EntityFrameworkCore.Sqlite" Version="3.5.0" />
<PackageReference Include="Elsa.CSharp" Version="3.5.0" />
<PackageReference Include="Elsa.JavaScript" Version="3.5.0" />
<PackageReference Include="Elsa.Liquid" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="App_Data\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
using System.Text.Encodings.Web;
using Elsa.EntityFrameworkCore.Extensions;
using Elsa.EntityFrameworkCore.Modules.Management;
using Elsa.EntityFrameworkCore.Modules.Runtime;
using Elsa.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
var identitySection = configuration.GetSection("Identity");
var identityTokenSection = identitySection.GetSection("Tokens");
builder.Services.AddElsa(elsa =>
{
elsa
// Discovers everything in this assembly decorated with [Activity] — including anything you drop under Activities/.
.AddActivitiesFrom<Program>()
.UseIdentity(identity =>
{
identity.TokenOptions = options => identityTokenSection.Bind(options);
identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options));
})
.UseDefaultAuthentication()
.UseWorkflowManagement(m => m.UseEntityFrameworkCore(ef => ef.UseSqlite()))
.UseWorkflowRuntime(r => r.UseEntityFrameworkCore(ef => ef.UseSqlite()))
.UseScheduling()
.UseWorkflowsApi()
.UseHttp(http => http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options))
.UseCSharp()
.UseJavaScript()
.UseLiquid(liquid => liquid.FluidOptions = options => options.Encoder = HtmlEncoder.Default);
});
// Studio (whichever host) is on a different origin, so allow it in.
builder.Services.AddCors(cors => cors.AddDefaultPolicy(p => p
.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin()
.WithExposedHeaders("x-elsa-workflow-instance-id")));
builder.Services.AddHealthChecks();
// Traefik terminates TLS in front of us; without this, self-generated URLs come out as http://
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.All;
o.KnownNetworks.Clear();
o.KnownProxies.Clear();
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseCors();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseWorkflowsApi();
app.UseWorkflows();
app.UseJsonSerializationErrorHandler();
app.MapHealthChecks("/health");
app.MapGet("/", () => Results.Ok(new { name = "Elsa Custom Server", status = "ok" }));
app.Run();

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}

View File

@@ -0,0 +1,42 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
},
"Http": {
"BaseUrl": "https://elsa-api.tes.gd",
"BasePath": "/workflows"
},
"Identity": {
"Tokens": {
"SigningKey": "CHANGE_ME_VIA_ENV_Identity__Tokens__SigningKey",
"AccessTokenLifetime": "1:00:00",
"RefreshTokenLifetime": "1.00:00:00"
},
"Roles": [
{
"Id": "admin",
"Name": "Administrator",
"Permissions": [ "*" ]
}
],
"Users": [
{
"Id": "admin",
"Name": "admin",
"Email": "admin@elsa.local",
"HashedPassword": "",
"HashedPasswordSalt": "",
"Roles": [ "admin" ]
}
],
"Applications": []
}
}