Skip to content

Astro

Railway.Website.Astro deploys an Astro project to Railway as a container. Server-rendered pages run on a Railway.Service (port 3000); static files are served first, then the Astro handler. Omit project and Alchemy creates a Railway.Project. Your astro.config.* loads natively — don’t declare an adapter.

The build integration is not bundled with alchemy. Install @alchemy.run/frontend-frameworks; the resource loads its /astro and /astro/node exports from your project at deploy time. It is only used at build time, so a dev dependency is enough:

Terminal window
bun add -d @alchemy.run/frontend-frameworks

Your astro.config.* file loads natively — integrations, Vite plugins, and any other non-serializable options work exactly as they do outside Alchemy. Alchemy merges a programmatic config over it: the Node adapter is injected for you (don’t declare an adapter in your config — that fails the build with an actionable error), and options passed via the astro prop override the file’s — see Astro configuration below for the details.

Declare the site as a module-level const (rather than inline in the Stack):

alchemy.run.ts
import * as Railway from "alchemy/Railway";
export const Website = Railway.Website.Astro("Website");

Pass rootDir if package.json is not at .. Pass project to put the Service in a Project you already declared; otherwise the site creates Railway.Project("Project") under its namespace.

Yield the site from your Stack and return its URL:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyAstroSite",
{
providers: Railway.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Website;
return { url: site.url };
}),
);

Pages are server-rendered by default. Pages that export const prerender = true are prerendered at build time and served as static files.

On deploy site.url is the generated *.up.railway.app hostname (or https://{domain} when you set domain). site.service and site.project are undefined under alchemy dev.

Process environment is the top-level env prop — plain strings, or Redacted values. They are copied onto process.env before build and alchemy dev, and onto the Service at deploy:

alchemy.run.ts
export const Website = Railway.Website.Astro("Website", {
env: {
GREETING: "Hello from Alchemy!",
API_BASE: "https://api.example.com",
},
});

This is the hosted Node process, not Worker bindings.

The Astro server runs in Node in the container, so server-rendered pages and endpoints read the environment from process.env:

src/pages/index.astro
---
const greeting = process.env.GREETING ?? "hello";
---
<h1>{greeting}</h1>

Astro inlines PUBLIC_* keys at build time. Because env is applied before the build, those keys behave the same as in a local astro build.

With astro: { output: "static" } every page is prerendered at build time and the deploy is assets-only — no Astro handler, just a static-file server in the image:

export const Website = Railway.Website.Astro("Website", {
astro: { output: "static" },
assets: { notFoundHandling: "404-page" },
});

assets.notFoundHandling applies only to output: "static". "404-page" serves the built 404.html for unmatched routes; "single-page-application" answers misses with the index page (200) instead.

Your astro.config.* is the primary place to configure Astro — integrations, Vite plugins, and every other option (serializable or not) work as usual:

astro.config.ts
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
site: "https://blog.example.com",
integrations: [react()],
vite: {
plugins: [tailwindcss()],
},
});

The astro prop on the resource exposes common serializable options for deploy-specific overrides; Astro merges them over the config file (scalars override, arrays like integrations and vite.plugins concatenate after the file’s):

alchemy.run.ts
export const Website = Railway.Website.Astro("Website", {
astro: {
site: "https://preview.example.com",
},
});

Two options are managed for you regardless of the config file:

  • adapter — Alchemy injects the Node adapter. Declaring an adapter in astro.config.* fails the build with an actionable error.
  • output — Alchemy defaults it to "server" (superseding a file-level output), so pages render on demand in the container. Opt into a fully prerendered site with astro: { output: "static" } on the resource.

site, base, srcDir, publicDir, outDir, and trailingSlash are the other serializable keys on astro.

alchemy dev runs Astro’s own dev server (native HMR) instead of deploying; site.url is the local address and no Railway resources are created. Wrap the site in Alchemy.remote() to deploy the real Project and Service even during dev.

domain is a hostname string. The site attaches a Railway.CustomDomain (targetPort 3000) and url becomes https://{domain} instead of the generated *.up.railway.app:

export const Website = Railway.Website.Astro("Website", {
domain: "app.example.com",
});

Railway issues a Let’s Encrypt certificate once DNS is verified.