Docs

Complete multi-service application

In this example, we are going to deploy the entire data plane of a support application into a customer's Kubernetes cluster. It has a dashboard, API, background worker, scheduler, Postgres, Redis, and object storage.

Tickets, attachments, cached data, and background jobs stay in that cluster. Only one gateway is public; the databases, storage, and internal services remain on the customer's private network. You still ship the application as one versioned product rather than maintaining a separate deployment project for every customer.

We keep the existing service split. A public gateway receives browser traffic, while the dashboard, API, worker, databases, and storage communicate over the deployment's private network.

HTTPS enters through one gateway in the customer environment. The gateway routes to the dashboard and API; private workers, databases, and storage remain behind it.

We will read alien.ts as the service map: it points to each source directory or image, declares ports and environment variables, and links services to Storage. The application code remains in its normal service directories.

Only the gateway calls publicEndpoint(...). The other Containers use deployment-local service names such as api:3000 and postgres:5432; the customer does not need to expose every service or create public load balancers for internal traffic.

Read alien.ts as a service map

The public gateway routes browser requests to the dashboard and API. Everything else uses private service names inside the deployment.

alien.ts
const api = new alien.Container("api")
  .code({ type: "source", src: "./services/api", toolchain: { type: "typescript" } })
  .port(3000)
  .environment({
    DATABASE_URL: "postgres://app:app@postgres:5432/app",
    REDIS_URL: "redis://redis:6379",
  })
  .link(files)
  .permissions("app")
  .build()

const gateway = new alien.Container("gateway")
  .code({ type: "source", src: "./services/gateway", toolchain: { type: "docker" } })
  .port(8080)
  .publicEndpoint("web", 8080, "http")
  .build()

The complete alien.ts defines each service separately, then adds all of them to one Kubernetes stack.

Keep one public entry point

The gateway is a normal nginx Container. It sends /api/* to the API and everything else to the dashboard using the resource names from alien.ts:

services/gateway/nginx.conf
upstream api {
  server api:3000;
}

upstream dashboard {
  server dashboard:3000;
}

server {
  listen 8080;

  location /api/ {
    proxy_pass http://api/;
  }

  location / {
    proxy_pass http://dashboard;
  }
}

api and dashboard resolve inside the deployment. They are not public DNS records and do not need their own ingress configuration.

The same rule applies to Postgres and Redis:

alien.ts
.environment({
  DATABASE_URL: "postgres://app:app@postgres:5432/app?sslmode=disable",
  REDIS_URL: "redis://redis:6379",
})

This example deliberately models Postgres and Redis as Containers because it is showing how an existing service graph maps onto Alien. Postgres gets a persistent volume; Redis is used as an ordinary private service.

alien.ts
const postgres = new alien.Container("postgres")
  .code({ type: "image", image: "postgres:16-alpine" })
  .port(5432)
  .persistentStorage("10Gi")
  .environment({
    POSTGRES_DB: "app",
    POSTGRES_USER: "app",
    POSTGRES_PASSWORD: "app",
    PGDATA: "/data/postgres",
  })
  .build()

For a production application, move the password into a deployment secret or use Alien's first-class Postgres resource when its binding and lifecycle fit the application.

The API accepts file uploads and writes their contents to the files Storage resource:

services/api/src/index.ts
const files = storage(process.env.FILES_BUCKET ?? "files")

app.post("/issues/:id/files", async c => {
  const id = c.req.param("id")
  const payload = await c.req.json<{ filename: string; content: string }>()
  const fileId = randomUUID()
  const objectKey = `issues/${id}/${fileId}-${payload.filename}`

  await files.put(objectKey, new TextEncoder().encode(payload.content))
  await db.query(
    "insert into issue_files (id, issue_id, object_key, filename) values ($1, $2, $3, $4)",
    [fileId, id, objectKey, payload.filename],
  )

  return c.json({ objectKey }, 201)
})

Both the API and worker call .link(files) and use the app permission profile. The dashboard, gateway, scheduler, Postgres, and Redis do not receive the Storage binding because they do not use it.

Move background work through Redis

Processing an issue is asynchronous. The API records the job in Redis and pushes it onto a list:

services/api/src/index.ts
await redis.hset(`issue:${id}:job`, {
  status: "queued",
  queuedAt: new Date().toISOString(),
})

await redis.lpush("work:issues", JSON.stringify({
  issueId: id,
  requestedAt: Date.now(),
}))

The worker blocks on that private Redis list, reads the issue from Postgres, writes a summary to Storage, and marks the issue as processed:

services/worker/src/index.ts
const item = await redis.brpop("work:issues", 0)
const { issueId } = JSON.parse(item![1])
const issue = await db.query(
  "select id, title, body from issues where id = $1",
  [issueId],
)

const row = issue.rows[0]
const artifactKey = `artifacts/${row.id}/summary.txt`
await files.put(
  artifactKey,
  new TextEncoder().encode(`Issue: ${row.title}\n\n${row.body}`),
)

await db.query(
  "update issues set status = $1, updated_at = now() where id = $2",
  ["processed", row.id],
)

All of these calls stay inside the Kubernetes deployment. Browser traffic enters once through the gateway; it does not connect directly to Redis, Postgres, or Storage.

Add a private operational Command

The worker also exposes reprocess as an Alien Command:

services/worker/src/index.ts
const receiver = createCommandReceiver()

receiver.command("reprocess", async input => {
  if (
    typeof input !== "object" ||
    input === null ||
    !("issueId" in input) ||
    typeof input.issueId !== "string"
  ) {
    throw new TypeError("issueId must be a string")
  }

  const { issueId } = input
  await redis.lpush("work:issues", JSON.stringify({
    issueId,
    requestedAt: Date.now(),
  }))
  return { requeued: true, issueId }
})

void receiver.run().catch(error => {
  console.error("command receiver stopped", error)
})

Because this is a long-running Container, it leases Commands over outbound HTTPS. You can add an operational action for your control plane without publishing another endpoint or opening an inbound admin port in the cluster.

Run scheduled work as another service

The scheduler is intentionally simple. Every minute it calls a private API route using http://api:3000:

services/scheduler/src/index.ts
await fetch(`${process.env.API_URL}/internal/maintenance`, {
  method: "POST",
  headers: { "x-app-secret": process.env.APP_SECRET! },
})

This is a useful migration pattern for an existing cron process: keep it as a separate Container first. You can change the implementation later without changing the rest of the service graph.

Assemble the stack

The final stack makes Storage setup-owned and lets Alien reconcile every running service:

alien.ts
export default new alien.Stack("full-stack-microservices")
  .platforms(["kubernetes"])
  .add(files, "frozen")
  .add(postgres, "live")
  .add(redis, "live")
  .add(api, "live")
  .add(worker, "live")
  .add(scheduler, "live")
  .add(dashboard, "live")
  .add(gateway, "live")
  .permissions({
    profiles: {
      app: {
        files: ["storage/data-read", "storage/data-write"],
      },
    },
  })
  .build()

Build and release the complete stack

cd examples/full-stack-microservices
alien build --platform kubernetes
alien release --platform kubernetes

After it is running, follow one request from gateway to api, then inspect the worker and scheduler. That is easier than reading every service at once.

For a customer-owned cluster, release the application and let the customer's admin install it from the deployment portal:

1 · Release
alien release

Publishes a version. Nothing is deployed for a customer yet.

2 · Invite
alien onboard acme-corp

Creates a deployment link for that customer.

3 · Deploy

The customer opens the link and deploys into their environment.

The full service graph runs in the customer's Kubernetes cluster, while releases and deployment health remain visible from your Alien control plane.

What you built

You mapped a real multi-service application into one customer deployment without collapsing its architecture. The customer owns the cluster and application data; your release channel still updates the services as one product.

Source: examples/full-stack-microservices.

Next: Stacks and resources, Where it can run.

On this page