
Publishing Aspire App to Railway
Thursday, 20 August 2026
Introduction 🚀
I love Aspire locally. The AppHost is the model of the system: services, databases, caches, the lot. aspire run gives you the dashboard, health checks, and that excellent DX where a new laptop is "clone, run, you're in" rather than a half-day of env files.
I've recently found Railway which offers an superb developer first hosting experience. It's also very affordable with a great UI and UX. They also offer a really nice CLI tool, but it's not Aspire. So I wanted to create a first-class set of NuGet packages to plug that gap so you can get that same experience you have when pushing to Azure.
This is my package family, not an official Microsoft integration. The repo is intrepid-developer/aspire-hosting-railway. MIT, open source, currently tracking Aspire.Hosting 13.5 / net10.0. The current version is 13.5.0-preview.1, live on nuget.org. This is very much a preview, and I am not going to pretend it is production-ready just yet.
In this post I want to show you how the Railway compute environment sits next to the Aspire model you already know, what aspire publish and aspire deploy actually do, and the sharp edges I would rather tell you about now than have you discover at 11pm.
Let's dive in.
1. Same model, different compute environment 🧠
If you have been following my Aspire series, you already know the shape. I am not going to rehash Aspire 101. But you can head over to my other posts to understand more. This post is about a new compute environment on top of that model.
AddRailwayEnvironment is the Railway project. In Aspire language, that is the compute environment. The Railway environment name comes from Aspire --environment: Production maps to production, Staging maps to staging. Override it with WithRailwayEnvironmentName if you need something else.
Local aspire run is normal Aspire. It never needs a Railway token. It never talks to Railway. Postgres is still AddPostgres. Redis is still AddRedis. The dashboard, WithReference, WaitFor, and health checks keep working. The official client packages (Aspire.Npgsql, Aspire.StackExchange.Redis) keep working.
I've created PublishAsRailwayPostgres and PublishAsRailwayRedis so we can update the deploy step. They do not swap the local resource for a Railway-shaped fake. That is the whole point. You model the system once. Locally you get containers. On publish and deploy you get Railway templates, services, and variables.
That is the DX I care about at work and on personal projects. The AppHost stays the source of truth. The compute environment is a destination, not a rewrite.
2. The AppHost I actually ship in the playground 🛠️
The playground in the repo is the simplest honest AppHost I could write. It compiles against the public APIs via project references, not the NuGet packages. That is still the easiest clone-and-run: restore, aspire run, no token, no package restore from a feed.
If you want the packages in your own AppHost, the primary path is nuget.org. Pin 13.5.0-preview.1, or pass --prerelease:
dotnet add package IntrepidDeveloper.Aspire.Hosting.Railway --version 13.5.0-preview.1
<PackageReference Include="IntrepidDeveloper.Aspire.Hosting.Railway" Version="13.5.0-preview.1" />
The hosting packages live on nuget.org as IntrepidDeveloper.Aspire.Hosting.Railway, plus .PostgreSQL, .Redis, and .Storage. AppHost takes those. Consuming projects that call AddRailwayBucketClient take IntrepidDeveloper.Aspire.Railway.Storage, not the hosting package. The gallery page marks it as a prerelease. Use --prerelease or pin 13.5.0-preview.1. Search may still be catching up after the version-line change. That is normal first-publish behaviour for a new version, not a permanent unlist.
If you don't want NuGet you can also hit up my GitHub Packages instead (https://nuget.pkg.github.com/intrepid-developer/index.json, just remember you need PAT with read:packages).
Here is samples/Playground.AppHost/AppHost.cs:
var builder = DistributedApplication.CreateBuilder(args);
var ghcr = builder.AddContainerRegistry("ghcr", "ghcr.io", "intrepid-developer/playground");
var railway = builder.AddRailwayEnvironment("railway")
.WithContainerRegistry(ghcr);
var db = builder.AddPostgres("postgres").PublishAsRailwayPostgres();
var cache = builder.AddRedis("redis").PublishAsRailwayRedis();
var uploads = builder.AddRailwayBucket("uploads");
builder.AddProject<Projects.Api>("api")
.WithReference(db)
.WithReference(cache)
.WithReference(uploads)
.WaitFor(db)
.WithExternalHttpEndpoints();
builder.Build().Run();
And the API side, samples/Playground.Api/Program.cs:
builder.AddNpgsqlDataSource("postgres");
builder.AddRedisClient("redis");
builder.AddRailwayBucketClient("uploads");
AddRailwayBucketClient registers IAmazonS3. You keep talking to S3 the way you already would. Locally the bucket is an Adobe S3Mock container. On deploy it becomes a Railway bucket plus S3 credentials.
The third argument to AddContainerRegistry is the GHCR namespace, <owner>/<repository>. The two-argument form would push ghcr.io/api, and GHCR rejects that. AddContainerRegistry is still experimental (ASPIRECOMPUTE003). The playground keeps the registry in the sample so a clone-and-run AppHost matches what deploy actually needs.
This is needed because Railway has no image registry. This integration does not invent one, and it does not shell out to railway up. Railpack has no .NET support, so you use images or a Dockerfile. You need an IContainerRegistry in the model (GHCR or Docker Hub). Attach it after AddRailwayEnvironment.
If the registry is missing, deploy of image-based services fails with a message to add GHCR or Docker Hub. That is deliberate. I would rather the pipeline tell you the truth than pretend a push succeeded.
3. What publish and deploy actually do ⚙️
This integration uses Aspire 13.5 compute-environment and pipeline hooks (PipelineStepAnnotation, WellKnownPipelineSteps).
| Step | What it does today |
|---|---|
prepare-deployment-targets-{name} |
Materialises RailwayServiceResource children and DeploymentTargetAnnotation |
publish-{name} |
Writes railway-plan.json (parameter names and Railway expressions; WithEnvironment string literals go in as-is) plus a .env.example of captured parameter names |
deploy-{name} |
Resolves the account or workspace token, applies the plan over GraphQL to backboard.railway.com/graphql/v2, persists ids, and reports real progress or failures |
destroy-{name} |
Warns that teardown is not implemented. It is a documented stub. Confirmed operations do not include project or environment delete |
The plan file holds expressions and parameter names, not resolved token values. Bucket secrets never go into railway-plan.json or deployment state. That is not the same as "the plan is unconditionally secret-safe". WithEnvironment string literals are written as-is, so do not put a secret in a literal and then treat the plan as safe to commit. Do not commit tokens, .env files, or real project ids. The repo's SECURITY.md is the hygiene I follow, and PRs run Gitleaks.
Worth noting that empty optional captured parameters are omitted on deploy instead of aborting the run.
Deploy talks GraphQL. It does not wrap the Railway CLI. The apply path for a compute service is an allow-list, not a kitchen sink:
serviceCreate(always passenvironmentId)serviceInstanceUpdate(source.image)- variables
- optional
serviceDomainCreate serviceInstanceDeployV2
Official databases go through template plus templateDeployV2 with the fetched serializedConfig. If a workflow id is missing, apply fails, so make sure to check this is present.
Host addresses are host-only: {service}.railway.internal (lowercase). Endpoints and secrets are never concatenated into strings before Aspire resolves them.
Ids are persisted in IDeploymentStateManager (project, environment, service, bucket, template). Re-deploy does not create a second project. If you adopt an existing canvas, AsExisting() (or RAILWAY_PROJECT_ID + RAILWAY_ENVIRONMENT_ID) is the path.
railway-environment-id / RAILWAY_ENVIRONMENT_ID is the target environment, not a staging source. If you pass a production id on a staging deploy, you hit production. That is the sharpest adopt-existing footgun I have.
Staging has a default I want to be explicit about. If a staging environment is missing on deploy, the default is to duplicate production (environmentCreate with sourceEnvironmentId) when production exists. That duplicate seeds production service, template, and bucket ids and then only updates and deploys. An empty create is opt-in (CreateEmptyEnvironment). PR and ephemeral environments are not in this release. I am not going to document them as if they shipped.
4. Databases and buckets without swapping clients 💡
This is the bit I am happiest with.
Postgres and Redis stay official AddPostgres / AddRedis. PublishAsRailway* only change deploy. Your API still calls AddNpgsqlDataSource("postgres") and AddRedisClient("redis"). In publish mode, WithReference emits Railway references such as ${{postgres.DATABASE_URL}} (private), never the local Docker connection string.
Buckets are a real Aspire resource, AddRailwayBucket. Locally you get S3Mock. On deploy you get bucketCreate + bucketS3Credentials, and the S3 connection variables are upserted for you. The storage client connection string looks like this:
Endpoint=https://storage.railway.app;AccessKeyId=...;SecretAccessKey=...;Bucket=uploads;Region=auto;ForcePathStyle=false
AddRailwayBucketClient("uploads") (from IntrepidDeveloper.Aspire.Railway.Storage) builds IAmazonS3 from those fields. Local S3-compatible endpoints default to path-style. storage.railway.app uses virtual-hosted style.
Railway buckets are private. Talk to them with S3 credentials or presigned URLs. They are still not on private DNS, so do not model a bucket as {service}.railway.internal.
The image-less bucket service in the plan is a variable holder for references such as ${{uploads.ENDPOINT}}. It is not compute. It does not get an image and it does not get serviceInstanceDeployV2.
Identity and multi-site apps still need their own cookie domain and Data Protection story. This package does not provide that. If you are sharing auth cookies across Railway public domains, that is on you, the same as it would be on ACA or Compose.
5. Auth, adopt-existing, and the sharp edges 🤔
Use an account or workspace token. Project tokens cannot call projectCreate. I found that out the way you would expect: I tried the obvious token first, and Railway said no.
Aspire resource names cannot contain underscores, so the parameter resource is railway-token, bound from RAILWAY_TOKEN. CI may set RAILWAY_API_TOKEN or RAILWAY_TOKEN. Adopt-existing ids use railway-project-id / railway-environment-id, bound from RAILWAY_PROJECT_ID / RAILWAY_ENVIRONMENT_ID. railway-environment-id is the environment you will deploy into. It is not a source to clone from. Local aspire run does not need any of this.
Here is the rest of the list I would put on a sticky note:
- This is a preview. Current version
13.5.0-preview.1on nuget.org (and still on GitHub Packages), pinned to Aspire.Hosting 13.5.0. Not production-ready, not official Microsoft. - Destroy and teardown are not implemented. The destroy step warns. Do not expect
aspire deployto have a matching clean teardown yet. - There is no
railway uppath, and Railpack will not build .NET. Instead use images or a Dockerfile, and give the environment a registry. - PR and ephemeral environments are not in this release.
- Later, if the slice earns it: MySQL, Mongo, HA / PgBouncer, cron, PR clones. None of those are shipped.
- Never commit tokens. The plan file has names and expressions, not resolved tokens, but
WithEnvironmentliterals are written as-is. See SECURITY.md.
I would rather you clone the repo knowing what is unfinished than read a launch post that hides the stubs.
Closing 💭
The thing I keep coming back to is the same AppHost. Official Aspire resource types locally. A Railway project when you publish. An honest pipeline that talks GraphQL on an allow-list and fails when it should. First-class Postgres, Redis, and private S3-shaped buckets without swapping your clients. Open source, MIT, on nuget.org, and still a preview.
I built this because I wanted Railway to feel like a compute environment, not a weekend of YAML and hope. It's not an official library and it's very much In Preview and I'm working on it all the time as I get to know Railway more.
If you have been living in the Aspire model and wishing the last mile was not "Azure or figure it out", this is the slice I wish I had a year ago.
Call to Action 🚀
Give it a go, let me know what works and what doesn't. I hope this package is helpful for you and you get a lot out of it.
Star the repo if the model clicks. Open an issue if a sharp edge bites you. I promise to read them.
Let's build something great together 🚀
Chris
Comments (0)
No comments yet. Be the first to comment.
Subscribe so you don't miss out 🚀
Be the first to know about new blog posts, news, and more. Delivered straight to your inbox 📨