# Miren Docs
> Enjoy the Deploy
This file contains all documentation content in a single document following the llmstxt.org standard.
## Addons
Addons are managed services that Miren provisions and operates for your app. Instead of running your own database as a service, you configure an addon, and Miren handles the infrastructure — creating the server, injecting connection credentials, and cleaning up when you're done.
## Minimum working example
Declare an addon in `.miren/app.toml` and it's provisioned on the next deploy:
```toml
name = "myapp"
[services.web]
command = "npm start"
[addons.miren-postgresql]
variant = "small"
```
Miren creates the PostgreSQL server, injects `DATABASE_URL` (and the other `PG*` variables) into every service, and holds your app's start until the database is ready.
## Addons vs. Services
| | Addons | Services |
|---|--------|----------|
| **Setup** | One line in app.toml | Configure image, env, disks, scaling |
| **Management** | Miren provisions and manages | You manage the process |
| **Credentials** | Automatically injected as env vars | You configure manually |
| **Best for** | Production databases, managed infrastructure | Custom software, full control |
If you just need a PostgreSQL database for your app, use an addon. If you need custom PostgreSQL extensions or full control over the configuration, run it as a [service](./services.md).
## Available Addons
| Addon | Description | Supported Versions | Default |
|-------|-------------|--------------------|---------|
| `miren-postgresql` | Managed PostgreSQL database | 14, 15, 16, 17, 18 | 18 |
| `miren-mysql` | Managed MySQL database | 8, 9 | 9 |
| `miren-valkey` | Managed Valkey key-value store (Redis-compatible) | 7, 8, 9 | 9 |
| `miren-rabbitmq` | Managed RabbitMQ message broker | 3, 4 | 4 |
| `miren-memcache` | Managed Memcached in-memory cache | 1.4, 1.5, 1.6 | 1.6 |
| `miren-sqlite` | Embedded SQLite database, continuously backed up | n/a | n/a |
List available addons on your cluster:
```miren
miren addon list-available
```
## Installing an Addon
### Via app.toml (recommended)
Declare addons in your `.miren/app.toml`. They're provisioned automatically on deploy:
```toml
name = "myapp"
[services.web]
command = "npm start"
[addons.miren-postgresql]
variant = "small"
```
### Via CLI
Attach an addon to an existing app:
```miren
miren addon create miren-postgresql:small -a myapp
```
## Version Selection
Each addon uses a default software version (e.g., PostgreSQL 17). You can choose a different version when installing an addon.
### Via app.toml
```toml
[addons.miren-postgresql]
variant = "small"
version = "16"
```
### Via CLI
```miren
miren addon create miren-postgresql:small -a myapp --version 16
```
The `version` value is typically a tag from the [supported versions](#available-addons) listed above (e.g., `16` or `17`).
If no version is specified, the addon's default version is used.
Miren validates that the image is accessible in its registry before provisioning begins. If the image cannot be found, the `addon create` or deploy will fail immediately with a clear error.
### Custom images
If you need a custom build of the addon software (e.g., with additional extensions or patches), you can set `version` to a full OCI image reference:
```toml
[addons.miren-postgresql]
variant = "small"
version = "registry.example.com/custom-postgres:16-custom"
```
When the version value contains `:`, Miren uses it as the complete image reference instead of appending it as a tag to the addon's default base image. The image must be accessible from the cluster at provisioning time.
## Environment Variables
When an addon is provisioned, Miren injects connection credentials as environment variables into your app. These are available to all services in the app. Variables marked sensitive are redacted in `miren env list` output.
### PostgreSQL
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | Full connection URL (sensitive) | `postgres://user:pass@host:5432/dbname` |
| `PGHOST` | Database hostname | `10.10.0.196` |
| `PGPORT` | Database port | `5432` |
| `PGUSER` | Database username | `myapp` |
| `PGPASSWORD` | Database password (sensitive) | — |
| `PGDATABASE` | Database name | `myapp` |
Most frameworks and ORMs connect automatically using `DATABASE_URL`.
### MySQL
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | Full connection URL (sensitive) | `mysql://user:pass@host:3306/dbname` |
| `MYSQL_HOST` | Database hostname | `10.10.0.196` |
| `MYSQL_PORT` | Database port | `3306` |
| `MYSQL_USER` | Database username | `myapp` |
| `MYSQL_PASSWORD` | Database password (sensitive) | — |
| `MYSQL_DATABASE` | Database name | `myapp` |
### Valkey
Valkey is wire-compatible with Redis, so Miren injects both `VALKEY_*` and `REDIS_*` variables pointing at the same server. Use whichever your client library expects.
| Variable | Description | Example |
|----------|-------------|---------|
| `VALKEY_URL` / `REDIS_URL` | Full connection URL (sensitive) | `redis://:pass@host:6379` |
| `VALKEY_HOST` / `REDIS_HOST` | Server hostname | `10.10.0.196` |
| `VALKEY_PORT` / `REDIS_PORT` | Server port | `6379` |
| `VALKEY_PASSWORD` / `REDIS_PASSWORD` | Password (sensitive) | — |
### RabbitMQ
| Variable | Description | Example |
|----------|-------------|---------|
| `RABBITMQ_URL` | Full AMQP connection URL (sensitive) | `amqp://user:pass@host:5672/%2F` |
| `RABBITMQ_HOST` | Broker hostname | `10.10.0.196` |
| `RABBITMQ_PORT` | Broker port | `5672` |
| `RABBITMQ_USER` | Broker username | `myapp` |
| `RABBITMQ_PASSWORD` | Broker password (sensitive) | — |
| `RABBITMQ_VHOST` | Virtual host | `/` |
### Memcached
| Variable | Description | Example |
|----------|-------------|---------|
| `MEMCACHE_URL` | Connection URL | `memcache://host:11211` |
| `MEMCACHE_HOST` | Server hostname | `10.10.0.196` |
| `MEMCACHE_PORT` | Server port | `11211` |
### SQLite
```bash
DATABASE_URL=file:/data/data.db
SQLITE_PATH=/data/data.db
```
The database is a file inside your own container, not a server, so there is no
host, port or password. Open `/data/data.db` and it is already there, already in
WAL mode.
### Inspecting injected variables
You can verify the variables are set on your app:
```miren
miren env list -a myapp
```
## SQLite is different
Every other addon runs a server your app connects to over the network. SQLite is
an in-process library: there is no server, and nothing to connect to. The addon
instead attaches a database file to your app's own container and replicates it
to the coordinator as you write.
```toml
[addons.miren-sqlite]
variant = "standard"
```
:::warning[SQLite is still early]
The SQLite addon's backup path still has sharp edges. Per-app backup
authorization is not yet implemented. If a runner starts while the coordinator
is unreachable, backups stay off until the runner restarts. Use it for data you
can recover, and [tell us what breaks](https://github.com/mirendev/runtime/issues).
:::
That difference has consequences worth knowing:
- **One writer.** SQLite allows a single writer, so a service with a SQLite
database must use `mode = "fixed"` with `num_instances = 1`. Deploying without
that fails with the reason, rather than starting an app whose database is
quietly missing.
- **One database per app.** An app gets a single SQLite database, because an app
gets a single instance of any addon.
- **Scoped with `services`.** By default the database attaches to every service,
which means every service inherits the single-writer rule. Name the ones that
need it and the rest are free to scale:
```toml
[addons.miren-sqlite]
services = ["web"]
```
- **Node-local.** The database lives on the server your app runs on, so the app
is pinned to the coordinator like any app with a disk.
:::warning[Replication is asynchronous]
Committed transactions reach the coordinator continuously in the background, not
synchronously. A server that dies abruptly can lose the last moment of writes.
:::
## Variants
Each addon offers variants that control the resource allocation and architecture:
```miren
miren addon variants miren-postgresql
```
### PostgreSQL & MySQL Variants
PostgreSQL and MySQL each offer two variants:
| Variant | Description | Use case |
|---------|-------------|----------|
| `small` | Dedicated server (1 GB storage) | Production apps needing isolation |
| `shared` | Multi-app shared server | Development, staging, or small apps |
**Dedicated** (`small`): Each app gets its own database instance with dedicated storage. Best for production workloads where you need isolation and predictable performance. Start here if your app might grow.
**Shared** (`shared`): Multiple apps share a single database server, each with their own logical database and credentials. The shared server does not isolate workloads — a heavy query in one app can affect others on the same server. This variant is designed for small internal tools, staging environments, and apps you know will stay lightweight.
### Valkey, RabbitMQ, and Memcached Variants
These addons currently offer only the dedicated `small` variant — each app gets its own server instance.
If no variant is specified, the default (`small`) is used.
:::note[Changing variants]
There is currently no way to migrate between variants (e.g. upgrading from `shared` to `small`). If you need to switch, you would need to back up your data, destroy the addon, recreate it with the new variant, and restore. We plan to add in-place variant upgrades in a future release.
:::
## Addon Lifecycle
### Provisioning
When you deploy an app with addons or run `addon create`, Miren:
1. Creates the addon association (status: **pending**)
2. Provisions the backing infrastructure (status: **provisioning**)
3. Injects environment variables into your app configuration
4. Marks the addon as ready (status: **active**)
5. Starts your app with the injected credentials
Provisioning typically takes 1–2 minutes for a new dedicated server (longer if the PostgreSQL image needs to be pulled for the first time).
:::note[Deploys wait for addons]
`miren deploy` does not activate a new version until every addon declared in app.toml is **active**. While it waits, the deploy output shows each addon's progress:
```
Provisioning addon miren-postgresql (small)...
Addon miren-postgresql ready
```
Deploy-triggered tasks (such as migrations) run only after this point, so they always see the addon's connection variables. If an addon fails to provision, the deploy fails and prints the reason; fix the cause, run `miren addon destroy` for that addon, and redeploy.
An addon attached with `addon create` to an app that is already running follows the same rule: Miren holds off launching the app's processes until the addon reaches active status.
:::
### Checking Status
List addons attached to your app:
```miren
miren addon list -a myapp
```
The `STATUS` column shows where each addon is in its lifecycle:
| Status | Meaning |
|---|---|
| `pending` | Requested, provisioning has not started |
| `provisioning` | The backing service is being created |
| `active` | Ready; connection variables are injected into the app |
| `error` | Provisioning failed. The reason is shown next to the status. Destroy the addon and recreate it once the cause is fixed. |
| `deprovisioning` | Being removed |
### Removing an Addon
Remove an addon and delete its data:
```miren
miren addon destroy miren-postgresql -a myapp
```
:::danger[Destroying an addon is permanent]
Destroying an addon permanently deletes the database and all its data. This cannot be undone.
:::
To remove an addon via app.toml, delete the `[addons.miren-postgresql]` section and redeploy. Miren detects the removal and deprovisions the addon.
## Example: Bun + PostgreSQL
A simple web server that tracks page visits using PostgreSQL:
**`.miren/app.toml`**:
```toml
name = "my-bun-app"
[services.web]
command = "bun run index.ts"
[addons.miren-postgresql]
variant = "shared"
```
**`index.ts`**:
```typescript
const sql = new SQL({ url: process.env.DATABASE_URL });
await sql`
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
const server = Bun.serve({
port: process.env.PORT || 3000,
async fetch(req) {
await sql`INSERT INTO visits DEFAULT VALUES`;
const [{ count }] = await sql`SELECT COUNT(*) as count FROM visits`;
return new Response(`Visits: ${count}\n`);
},
});
```
Deploy:
```miren
miren deploy
```
Miren provisions PostgreSQL, injects `DATABASE_URL`, and starts your app once the database is ready.
## Backing Up and Restoring
:::info[Early version]
Addon backup and restore uses the general-purpose disk backup system. We plan to add addon-aware backup commands in a future release that will simplify this workflow. For now, the steps below work reliably for PostgreSQL addon data.
The `disk backup` and `disk restore` commands must be run directly on the server (via SSH or `miren ssh`), not from your local machine. Remote backup support is planned.
:::
Each PostgreSQL addon stores its data on a Miren disk. You can back up and restore this disk using the `miren disk backup` and `miren disk restore` commands.
### Finding the Disk Name
List disks to find the one belonging to your addon:
```miren
miren debug disk list
```
Addon disks are named with a `pg-` prefix. For dedicated (`small`) addons, the name includes your app name (e.g. `pg-pg-myapp-s...-data`). For shared addons, it starts with `pg-shared-data-`.
### Creating a Backup
Back up the disk to a compressed snapshot file. This must be run on the server:
```miren
miren disk backup -n
```
This creates a timestamped `.miren.zst` file in the current directory. If the disk is currently in use, the backup will be crash-consistent (safe for PostgreSQL, which uses write-ahead logging).
Example:
```miren
miren disk backup -n pg-pg-myapp-sCZDabc123-data
# Output: pg-pg-myapp-sCZDabc123-data-20260324-120000.miren.zst
```
You can specify a custom output path with `-o`:
```miren
miren disk backup -n pg-pg-myapp-sCZDabc123-data -o /backups/myapp-db.miren.zst
```
### Restoring from a Backup
To restore from a backup, provide the snapshot file. This must also be run on the server:
```miren
miren disk restore -s
```
The restore procedure recreates the disk with the original name. If the disk already exists, use `--force` to overwrite:
```miren
miren disk restore -s myapp-db.miren.zst --force
```
To restore to a different disk name:
```miren
miren disk restore -s myapp-db.miren.zst -n new-disk-name
```
After restoring, restart your app to pick up the restored data:
```miren
miren app restart myapp
```
### Backup Recommendations
- **Schedule regular backups** for production databases, especially before destructive operations like `addon destroy`
- **Store backups off-server** — copy snapshot files to external storage
- **Test restores periodically** to verify your backups are valid
- Backups are compressed with zstd and include checksum verification
---
## Admin Interface
The admin interface allows you to expose custom administrative functions in your application that can be called from the CLI or other tooling. This is useful for user management, cache clearing, database operations, and other maintenance tasks.
## Minimum working example
Handle JSON-RPC 2.0 at `/.well-known/miren/admin` in your web service, validating the injected `ADMIN_TOKEN`:
```typescript
const adminToken = process.env.ADMIN_TOKEN;
Bun.serve({
port: process.env.PORT ?? 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method !== 'POST' || url.pathname !== '/.well-known/miren/admin') {
return new Response('Not Found', { status: 404 });
}
if (!adminToken || req.headers.get('authorization') !== `Bearer ${adminToken}`) {
return new Response('Unauthorized', { status: 401 });
}
const { method, id } = await req.json();
if (method === 'ping') return Response.json({ jsonrpc: '2.0', id, result: 'pong' });
return Response.json({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } });
},
});
```
Deploy, then call it from the CLI:
```miren
miren admin -a myapp ping
```
The [complete examples](#complete-example-go) below add introspection, typed parameters, and constant-time token comparison.
## How It Works
Your application exposes admin methods via a JSON-RPC 2.0 endpoint at a well-known path. When you run `miren admin`, the CLI:
1. Looks up your app and retrieves the admin token
2. Sends a JSON-RPC request to your app's web service
3. Returns the response (or error) to you
```text
miren admin delete-user user_id=123 --app myapp
|
v
JSON-RPC POST to /.well-known/miren/admin
Authorization: Bearer
{"jsonrpc":"2.0","method":"delete-user","params":{"user_id":"123"},"id":1}
|
v
Your app processes the request and returns a response
```
## Implementing the Admin Endpoint
### Endpoint Requirements
Your web service must expose:
| Requirement | Value |
|-------------|-------|
| Path | `/.well-known/miren/admin` |
| Method | POST |
| Content-Type | `application/json` |
| Protocol | JSON-RPC 2.0 |
### Security
Admin calls are authenticated using a bearer token that Miren generates for your app. Your app receives this token via the `ADMIN_TOKEN` environment variable and must validate it on every request.
#### The admin token
- **Format**: 32 random bytes, a random bearer token
- **Per-version**: a fresh token is generated for every new app version at build time, so each deploy rotates the token automatically.
- **Reserved env var**: `ADMIN_TOKEN` is injected by the runtime. It is appended after your own env vars so it cannot be overridden from `miren.toml`, the CLI, or build-time env.
**Validate the token on every request, for example in Go:**
```go
func authMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if subtle.ConstantTimeCompare(
[]byte(strings.TrimPrefix(auth, "Bearer ")),
[]byte(token),
) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
```
#### Network reachability and `X-Miren-Access`
Miren's admin proxy talks to your app over its **internal** HTTP ingress path, never via the public route. Any public request to `/.well-known/miren/admin` is rejected with `404 Not Found` by the ingress before it can reach your app, so in practice your handler only ever sees requests carrying the `X-Miren-Access: internal` header.
For reference, the two values the runtime uses are:
- `X-Miren-Access: internal` — request originated from `miren admin` and was routed through Miren's internal admin path.
- `X-Miren-Access: public` — request arrived through the public ingress. Miren overwrites any client-supplied value here before forwarding, so the header cannot be spoofed by external callers.
The bearer token is the primary guard; checking for `X-Miren-Access: internal` is a defense-in-depth signal in case the well-known path is ever exposed through a custom route.
### Auditing
Every admin call is appended to the **app's log stream** as an out-of-band entry (`source=admin`, `method=`) including the method name, params payload size, status (`ok` / `error=...`), and duration in milliseconds. You can review the audit trail with:
```bash
miren logs
```
This is server-side bookkeeping — your handler doesn't need to log calls itself to get an audit record.
### JSON-RPC 2.0 Format
Requests follow the standard JSON-RPC 2.0 format:
```json
{
"jsonrpc": "2.0",
"method": "method-name",
"params": {"key": "value"},
"id": 1
}
```
`params` may be a JSON **object** (named arguments, recommended) or a JSON **array** (positional arguments). When the CLI describes a method that uses positional arguments via `$methods`, it labels the entries `arg0`, `arg1`, etc.
#### Call timeout
Each admin call has a fixed **30-second** timeout enforced by the runtime. Handlers should return promptly — if work takes longer, enqueue it on a background worker and return a job handle the caller can poll on a subsequent admin method.
#### HTTP status semantics
The runtime expects a `200 OK` response carrying a JSON-RPC envelope (success *or* error). A non-200 HTTP status is surfaced to the caller as a generic `admin endpoint returned status N` message with no structured detail. Prefer returning a JSON-RPC error object over a raw HTTP error code so the CLI can render a useful message and error code.
Successful responses:
```json
{
"jsonrpc": "2.0",
"result": {"any": "data"},
"id": 1
}
```
Error responses:
```json
{
"jsonrpc": "2.0",
"error": {
"code": -32001,
"message": "user not found",
"data": {"user_id": "123"}
},
"id": 1
}
```
### Standard Error Codes
| Code | Meaning |
|------|---------|
| -32700 | Parse error (invalid JSON) |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| < 0 | Application-specific errors |
## Method Introspection
When you run `miren admin --list`, Miren sends a JSON-RPC request with the reserved method name `$methods` to your admin endpoint. If your app handles this method, it should return an array of objects describing the available admin methods. This is optional — if your app doesn't handle `$methods`, the `--list` command will report an error, but regular method calls still work.
:::note[Reserved method names]
Names beginning with `$` are reserved for the runtime. The CLI currently uses `$methods` for discovery and filters both `$methods` and `$type` out of `--list` output, so don't expose business logic under those names.
:::
The `$methods` request has no params:
```json
{
"jsonrpc": "2.0",
"method": "$methods",
"id": 1
}
```
Response:
```json
{
"jsonrpc": "2.0",
"result": [
{
"name": "list-users",
"description": "List all users in the system",
"category": "users",
"params": {"limit": "number", "offset": "number"}
},
{
"name": "get-user",
"description": "Get a specific user by ID",
"category": "users",
"params": {"user_id": "string"}
},
{
"name": "clear-cache",
"description": "Clear the application cache",
"category": "maintenance"
}
],
"id": 1
}
```
Method metadata fields:
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Method name |
| `description` | No | Human-readable description |
| `category` | No | Grouping for display (e.g., "users", "maintenance") |
| `params` | No | Parameter definitions as `{"name": "type"}` |
## Complete Example (Go)
Here's a complete example using the `jsonrpc3` library:
```go
package main
"context"
"crypto/subtle"
"log"
"net/http"
"os"
"strings"
"miren.dev/jsonrpc3/go/jsonrpc3"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
adminToken := os.Getenv("ADMIN_TOKEN")
if adminToken == "" {
log.Println("WARNING: ADMIN_TOKEN not set")
}
// Create the admin handler with method definitions
adminMethods := jsonrpc3.NewMethodMap()
// Register admin methods with introspection metadata
adminMethods.Register("list-users", listUsers,
jsonrpc3.WithDescription("List all users in the system"),
jsonrpc3.WithParams(map[string]string{
"limit": "number",
"offset": "number",
}),
jsonrpc3.WithCategory("users"),
)
adminMethods.Register("get-user", getUser,
jsonrpc3.WithDescription("Get a specific user by ID"),
jsonrpc3.WithParams(map[string]string{
"user_id": "string",
}),
jsonrpc3.WithCategory("users"),
)
adminMethods.Register("clear-cache", clearCache,
jsonrpc3.WithDescription("Clear the application cache"),
jsonrpc3.WithCategory("maintenance"),
)
// Create the HTTP handler for JSON-RPC
rpcHandler := jsonrpc3.NewHTTPHandler(adminMethods)
// Wrap with auth middleware
authHandler := authMiddleware(adminToken, rpcHandler)
// Mount admin endpoint at well-known path
http.Handle("/.well-known/miren/admin", authHandler)
log.Printf("Starting server on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func authMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if subtle.ConstantTimeCompare(
[]byte(strings.TrimPrefix(auth, "Bearer ")),
[]byte(token),
) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
// Sample data
var users = map[string]map[string]any{
"user-1": {"id": "user-1", "name": "Alice", "email": "alice@example.com"},
"user-2": {"id": "user-2", "name": "Bob", "email": "bob@example.com"},
}
type listUsersParams struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func listUsers(ctx context.Context, params jsonrpc3.Params, caller jsonrpc3.Caller) (any, error) {
p := listUsersParams{Limit: 10, Offset: 0}
if params != nil {
_ = params.Decode(&p)
}
var result []map[string]any
for _, user := range users {
result = append(result, user)
}
return map[string]any{
"users": result,
"total": len(users),
}, nil
}
type userIDParams struct {
UserID string `json:"user_id"`
}
func getUser(ctx context.Context, params jsonrpc3.Params, caller jsonrpc3.Caller) (any, error) {
var p userIDParams
if params == nil {
return nil, jsonrpc3.NewInvalidParamsError("user_id is required")
}
if err := params.Decode(&p); err != nil {
return nil, jsonrpc3.NewInvalidParamsError("invalid params")
}
if p.UserID == "" {
return nil, jsonrpc3.NewInvalidParamsError("user_id is required")
}
user, ok := users[p.UserID]
if !ok {
return nil, jsonrpc3.NewError(-32001, "user not found", nil)
}
return user, nil
}
func clearCache(ctx context.Context, params jsonrpc3.Params, caller jsonrpc3.Caller) (any, error) {
// Your cache clearing logic here
return map[string]any{
"cleared": true,
}, nil
}
```
## Other Languages
The admin interface is language-agnostic. Any language that can:
1. Handle HTTP POST requests
2. Parse and generate JSON
3. Implement the JSON-RPC 2.0 protocol
can expose admin methods. The key requirements are:
- Listen on `/.well-known/miren/admin`
- Validate the `Authorization: Bearer ` header against `ADMIN_TOKEN`
- Handle JSON-RPC requests and return proper responses
- Optionally implement `$methods` for introspection
See [More Implementation Examples](#more-implementation-examples) at the bottom of this page for ready-to-use Python, Node.js, and Bun snippets.
## Calling Admin Methods
Once your app exposes the admin interface, use the CLI to call methods. All commands target the active version of the app named with `--app` / `-a` (or inferred from the current directory).
### Discovery and help
```miren
# List every method the app advertises via $methods
miren admin --list -a myapp
# Show parameter signature for one method (--func-help or -h )
miren admin -a myapp get-user -h
miren admin -a myapp --func-help get-user
```
### Passing parameters
You can pass parameters three ways, and mix them freely in one call. The CLI rejects the call if the same key shows up via more than one channel.
```miren
# 1. Bare key=value pairs
miren admin -a myapp get-user user_id=user-1
# 2. Long flags: --key=value or --key value
miren admin -a myapp list-users --limit 50 --offset 0
# 3. A JSON params object from a file (use - for stdin)
miren admin -a myapp update-config -f settings.json
cat settings.json | miren admin -a myapp update-config -f -
# Mixed: file supplies defaults, flag overrides one field
miren admin -a myapp update-config -f settings.json --debug=true
```
#### Type-aware parsing
When the app advertises a parameter type via `$methods`, the CLI coerces the supplied string into that type before sending the JSON-RPC request:
| Declared type | Accepted CLI input |
|---------------|--------------------|
| `string` | any value, passed through |
| `number`, `integer`, `int`, `float` | numeric literal (`42`, `3.14`, `-5`) |
| `boolean`, `bool` | `true` / `false` / `1` / `0` / `yes` / `no` |
| `object` | JSON object literal (`'{"k":"v"}'`) |
| `array` | JSON array literal (`'[1,2,3]'`) |
If the app does not advertise types (or you pass `--no-validate`), the CLI tries to parse each value as JSON and falls back to a string.
#### Kebab-case flag names
If your method declares a snake_case parameter like `user_id`, you can write the equivalent kebab-case flag (`--user-id`) on the CLI and it will be normalized automatically. Keys that genuinely contain hyphens are left alone.
### Output format
The CLI chooses an output format based on context:
- **TTY**: human-friendly pretty rendering (tables for uniform arrays, key/value lists otherwise).
- **Non-TTY** (pipes, scripts): syntax-highlighted JSON.
Override with `--json` to force JSON or `--pretty` to force the rendered form.
### Skipping validation
If your app does not implement `$methods`, the CLI silently skips validation. To suppress validation explicitly — for example to call an undeclared diagnostic method — pass `--no-validate`:
```bash
miren admin -a myapp --no-validate debug-internal
```
See [Admin Commands](./command/admin.md) for the full CLI flag reference.
## More Implementation Examples
### Python Example
Using Flask:
```python
from flask import Flask, request, jsonify
app = Flask(__name__)
ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', '')
@app.route('/.well-known/miren/admin', methods=['POST'])
def admin_endpoint():
# Validate token
auth = request.headers.get('Authorization', '')
if ADMIN_TOKEN and not hmac.compare_digest(auth, f'Bearer {ADMIN_TOKEN}'):
return 'Unauthorized', 401
data = request.json
method = data.get('method')
params = data.get('params', {})
req_id = data.get('id')
# Handle introspection
if method == '$methods':
return jsonify({
'jsonrpc': '2.0',
'result': [
{'name': 'get-stats', 'description': 'Get app statistics'},
],
'id': req_id
})
# Handle your methods
if method == 'get-stats':
return jsonify({
'jsonrpc': '2.0',
'result': {'users': 42, 'requests': 1000},
'id': req_id
})
return jsonify({
'jsonrpc': '2.0',
'error': {'code': -32601, 'message': 'Method not found'},
'id': req_id
})
```
### Node.js Example
Using Express:
```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
function tokenMatches(supplied, expected) {
const a = Buffer.from(supplied);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/.well-known/miren/admin', (req, res) => {
const auth = req.get('authorization') || '';
if (ADMIN_TOKEN) {
if (!auth.startsWith('Bearer ') || !tokenMatches(auth.slice(7), ADMIN_TOKEN)) {
return res.status(401).send('Unauthorized');
}
}
const { method, params = {}, id } = req.body;
if (method === '$methods') {
return res.json({
jsonrpc: '2.0',
id,
result: [
{
name: 'get-stats',
description: 'Get app statistics',
category: 'maintenance',
},
{
name: 'get-user',
description: 'Get a specific user by ID',
category: 'users',
params: { user_id: 'string' },
},
],
});
}
if (method === 'get-stats') {
return res.json({
jsonrpc: '2.0',
id,
result: { users: 42, requests: 1000 },
});
}
if (method === 'get-user') {
if (!params.user_id) {
return res.json({
jsonrpc: '2.0',
id,
error: { code: -32602, message: 'user_id is required' },
});
}
return res.json({
jsonrpc: '2.0',
id,
result: { id: params.user_id, name: 'Alice' },
});
}
return res.json({
jsonrpc: '2.0',
id,
error: { code: -32601, message: 'Method not found' },
});
});
const port = process.env.PORT || 8080;
app.listen(port, () => console.log(`listening on :${port}`));
```
### Bun Example
Bun's built-in HTTP server needs no dependencies:
```typescript
const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? '';
function tokenMatches(supplied: string, expected: string): boolean {
const a = Buffer.from(supplied);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
type RpcRequest = {
jsonrpc: '2.0';
method: string;
params?: Record | unknown[];
id: number | string | null;
};
function rpc(id: RpcRequest['id'], body: object): Response {
return Response.json({ jsonrpc: '2.0', id, ...body });
}
Bun.serve({
port: Number(process.env.PORT ?? 8080),
async fetch(req) {
const url = new URL(req.url);
if (req.method !== 'POST' || url.pathname !== '/.well-known/miren/admin') {
return new Response('Not Found', { status: 404 });
}
if (ADMIN_TOKEN) {
const auth = req.headers.get('authorization') ?? '';
if (!auth.startsWith('Bearer ') || !tokenMatches(auth.slice(7), ADMIN_TOKEN)) {
return new Response('Unauthorized', { status: 401 });
}
}
const { method, params = {}, id } = (await req.json()) as RpcRequest;
const p = params as Record;
switch (method) {
case '$methods':
return rpc(id, {
result: [
{ name: 'get-stats', description: 'Get app statistics', category: 'maintenance' },
{
name: 'get-user',
description: 'Get a specific user by ID',
category: 'users',
params: { user_id: 'string' },
},
],
});
case 'get-stats':
return rpc(id, { result: { users: 42, requests: 1000 } });
case 'get-user':
if (!p.user_id) {
return rpc(id, { error: { code: -32602, message: 'user_id is required' } });
}
return rpc(id, { result: { id: p.user_id, name: 'Alice' } });
default:
return rpc(id, { error: { code: -32601, message: 'Method not found' } });
}
},
});
```
## Next Steps
- [Admin Commands](./command/admin.md) — CLI reference for `miren admin`
- [Services](./services.md) — Configure your app's web service
- [Getting Started](./getting-started.md) — Deploy your first app
---
## Agent Skills
You shouldn't have to context-switch out of your editor to deploy an app or check why something's unhealthy. Miren's agent skills let your AI coding agent operate your infrastructure directly — deploy, diagnose, and manage apps without leaving the conversation.
The skills work with [Claude Code](https://claude.ai/code), [Codex](https://github.com/openai/codex), [Amp](https://ampcode.com), [Pi](https://pi.dev), and [OpenCode](https://github.com/opencode-ai/opencode). Source and setup instructions are at [github.com/mirendev/miren-skills](https://github.com/mirendev/miren-skills).
:::note[Docs are the source of truth]
Skills make these docs faster to act on — your agent can read a page about scaling and immediately run the commands — but the docs remain the authoritative reference. When in doubt, the docs are the source of truth.
:::
## Installation
### Claude Code
```bash
/plugin marketplace add mirendev/miren-skills
/plugin install miren@miren
```
### Codex CLI
```bash
git clone https://github.com/mirendev/miren-skills
cp -r miren-skills/.agents/skills/* ~/.agents/skills/
```
### Amp
From the command palette (`Ctrl+O` in CLI, `Cmd+Shift+P` in VS Code):
```text
skill: add https://github.com/mirendev/miren-skills
```
### Pi
```bash
pi install git:github.com/mirendev/miren-skills
```
### OpenCode
```bash
git clone https://github.com/mirendev/miren-skills
cp -r miren-skills/.agents/skills/* ~/.config/opencode/skills/
```
## What's included
### `use-miren`
The core skill. Once installed, your agent knows how to use the `miren` CLI — it discovers commands via `miren help`, targets clusters with `-C`, and uses `--json` output for reliable parsing. You don't need to teach it anything; just mention Miren and it kicks in.
### `app-setup`
Getting a new app onto Miren means figuring out what it needs — env vars, databases, services, build config — and wiring it all up. This agent does the detective work for you. Point it at your source code and it walks you through the whole setup, from stack detection to a working `.miren/app.toml`.
Try asking:
- "Help me set up this app on Miren"
- "What does this app need to run?"
### `app-health`
Instead of piecing together app status from multiple commands, ask your agent to check on an app. It pulls together service states, deployment history, logs, and diagnostics into a single report with actionable recommendations. Defaults to the app in your current directory.
Try asking:
- "How's this app doing?"
- "Check the health of myapp"
### `cluster-health`
Same idea, but across your whole cluster. Surveys every app and service, then gives you a prioritized breakdown — what's healthy, what needs attention, and what to do about it.
Try asking:
- "How's the cluster looking?"
- "Give me a health check on garden"
## Commands reference
The skills teach agents to discover commands on their own via `miren help`:
```bash
miren help --commands # list all commands
miren help app list # help for a specific command
```
Most commands accept `-C ` to target a specific cluster.
---
## App Configuration
Miren uses a **convention over configuration** approach. Most apps need little configuration: Miren can detect your language and build an image, or run a configured image with its own startup defaults. When you need to customize, you add a `.miren/app.toml` file.
## Minimum working example
The smallest useful `.miren/app.toml` names the app and its start command:
```toml title=".miren/app.toml"
name = "myapp"
[services.web]
command = "npm start"
```
Deploy with `miren deploy` and Miren builds the image and runs `web` with that command. Everything else on this page is additive: environment variables, more services, scaling, disks.
:::tip[Have an agent inspect your app]
Install the [Miren agent skills](./agent-skills.md) and ask your AI coding agent to
"set up this app on Miren." The `app-setup` skill can inspect the repository,
identify the services and environment variables it needs, and prepare a working
`.miren/app.toml`. Use the rest of this page when you want to review or customize
the result.
:::
## When You Don't Need app.toml
If your app is a single web service with a standard language stack, Miren handles everything:
- **Language and build**: Detected from your project files (`package.json`, `go.mod`, `Gemfile`, etc.) — see [Language Guides](./guides/index.md)
- **Start command**: Detected from your framework or `Procfile`
- **Scaling**: Web services autoscale based on traffic by default
You can deploy with just:
```miren
miren init
miren deploy
```
## What `miren init` Does for You
`miren init` does more than scaffold a config file. It scans your project for the environment variables your app actually needs to boot, splits them into things it can handle for you and things it can't, and stages whatever it can find.
### Detection
For each supported stack (Python, Node.js, Bun, Go, Ruby, Rust), `miren init`:
- Reads your manifest (`Gemfile`, `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) to map known libraries to the env vars they typically expect — `pg` → `DATABASE_URL`, `@sentry/node` → `SENTRY_DSN`, and so on.
- Greps your source code for direct env reads (`ENV['X']`, `process.env.X`, `os.Getenv("X")`, `std::env::var("X")`, `Bun.env.X`) and notes whether each one has a fallback.
- Parses any `.env.sample` / `.env.example` files in the repo as a declaration of what's expected.
- Recognizes framework-specific names like `RAILS_ENV`, `NODE_ENV`, `RUST_LOG`, and `RAILS_MASTER_KEY`.
Each detected variable gets a confidence: **required**, **recommended**, or **optional**. A direct source reference without a fallback is required. Library-based guesses are recommended unless the source confirms them, in which case they're elevated. Variables with a default-valued fallback in code (`process.env.X ?? "..."`, `cmp.Or(os.Getenv("X"), "...")`) are optional.
### Staging
For required variables, `miren init` tries to handle them automatically:
- **Has a sensible default** (e.g. `RAILS_ENV=production`) → written to `app.toml` so it's visible.
- **Can be generated** (e.g. Rails `SECRET_KEY_BASE`) → a cryptographically random value is generated and pre-set on the app, the same as if you'd run `miren config set` yourself.
- **Can be read from a local file** (e.g. `RAILS_MASTER_KEY` from `config/master.key` or `config/credentials/production.key`) → read from disk and pre-set on the app, again the same as `miren config set`.
- **Anything else** → listed as "must be configured manually" with `miren config set`.
Pre-set values are picked up by your first `miren deploy` automatically, so generated secrets and read-in keys are present from the very first build without an extra step.
Sensitive variables marked as such (whether by detection or because the key looks like a secret) are masked in CLI output and never written to `app.toml` in plaintext.
## When You Need app.toml
Create `.miren/app.toml` when you need to:
- **Run multiple services** — web server plus workers, databases, or caches
- **Set environment variables** — configuration your app reads at runtime
- **Tune scaling** — adjust concurrency thresholds or use fixed instance counts
- **Attach persistent disks** — for databases or file storage
- **Run an existing image** — deploy a prebuilt container without a Dockerfile or source build
- **Customize builds** — specify a Dockerfile, language version, or extra build steps
- **Configure addons** — managed databases and other backing services (see [Addons](./addons.md))
## Configuration Sections
Here's how the sections of `app.toml` map to your application's lifecycle:
### Build
The `[build]` section controls how Miren builds your container image. Override the detected language version, point to a custom Dockerfile, or add post-build steps.
```toml
[build]
version = "3.12"
onbuild = ["npm run build"]
```
See [Language Guides](./guides/index.md) for build details per language.
### Services
The `[services.]` sections define the processes your app runs. Each service gets its own command, scaling configuration, and optionally its own container image.
```toml
[services.web]
command = "node server.js"
[services.worker]
command = "node worker.js"
```
To deploy an existing image as the app, set `image` on the `web` service. Miren uses it directly when no Dockerfile is configured:
```toml
[services.web]
image = "ghcr.io/example/myapp:latest"
```
The optional `args` array replaces the image's `CMD` while preserving its
`ENTRYPOINT`. Miren passes the array directly, without shell expansion. Leave both
`args` and `command` unset to use the image's defaults unchanged; use `command` for
the existing full `/bin/sh -c` override. Miren also inherits a single TCP port from
the image's `EXPOSE` metadata. Set `port` when the image exposes no ports or several.
See [Services](./services.md) for patterns like running databases alongside your app.
### Scaling
Each service has a `[services..concurrency]` section that controls how it scales. Web services default to autoscaling; everything else defaults to a single fixed instance.
```toml
[services.web.concurrency]
mode = "auto"
requests_per_instance = 20
[services.worker.concurrency]
mode = "fixed"
num_instances = 3
```
See [Application Scaling](./scaling.md) for tuning guidance.
### Persistent Storage
Services can attach disks for data that needs to survive restarts. Disks use exclusive leasing and require fixed concurrency with a single instance.
```toml
[services.db.concurrency]
mode = "fixed"
num_instances = 1
[[services.db.disks]]
name = "postgres-data"
mount_path = "/var/lib/postgresql/data"
size_gb = 20
```
See [Persistent Storage](./disks.md) for local shared storage and Miren Disks.
### Environment Variables
Environment variables are declared with `[[env]]` at the top level (available to all services) or `[[services..env]]` for a specific service. Service-level env vars are merged with global ones.
```toml
# Available to all services
[[env]]
key = "DATABASE_URL"
value = "postgres://db.app.miren:5432/myapp"
# Only for the worker service
[[services.worker.env]]
key = "WORKER_CONCURRENCY"
value = "5"
```
#### Env Var Metadata
Each env var supports optional metadata fields for documentation and validation:
```toml
[[env]]
key = "API_KEY"
value = ""
required = true
sensitive = true
description = "Third-party API key for payment processing"
```
| Field | Type | Description |
|-------|------|-------------|
| `key` | string | Variable name (required) |
| `value` | string | Variable value |
| `backend` | string | Secret backend to source the value from, instead of `value` — see [Secrets](./secrets.md) |
| `ref` | string | Reference to the secret within that backend |
| `required` | bool | If `true`, deploy will fail when this variable has no value |
| `sensitive` | bool | If `true`, the value is masked in CLI output and logs |
| `description` | string | Human-readable explanation of what this variable is for |
The `required` flag is useful for variables whose values differ per environment—declare them in `app.toml` with an empty value and `required = true`, then set the actual value with `miren env set` before deploying. The `sensitive` flag ensures secrets aren't accidentally exposed in terminal output.
#### Referencing a Secret
For a real credential, `sensitive` is not enough: it masks display but the value still sits in your config. Use `backend` and `ref` to point at a [secret](./secrets.md) instead, so `app.toml` holds only a pointer and stays safe to commit:
```toml
[[env]]
key = "STRIPE_API_KEY"
backend = "cluster"
ref = "payments/stripe-key"
```
Set `value` or `ref`, never both. A referenced variable is sensitive automatically.
#### Variables Miren Injects
Miren injects these automatically. You don't declare them, and your app can read them to find out what it is:
| Variable | Value | Injected |
|----------|-------|----------|
| `MIREN_RUNTIME_APP` | The app name | Every sandbox |
| `MIREN_RUNTIME_VERSION` | The deployed version, e.g. `v1` | Every sandbox |
| `MIREN_RUNTIME_INSTANCE_NUM` | This instance's number, starting at `0`. See [Scaling](./scaling.md). | Instance-backed sandboxes only |
Sandboxes also get `PORT` (the port your web service should listen on) and the [workload identity](./workload-identity.md) variables.
:::info[Injected vs. CLI variables]
`MIREN_RUNTIME_*` is the namespace Miren injects **into** your app; the `MIREN_IDENTITY_*` [workload identity](./workload-identity.md) variables are injected too. Most other `MIREN_*` variables — such as `MIREN_CLUSTER` and `MIREN_CONFIG` — are input **to** the `miren` CLI, used to pick what a command acts on (see [CI/CD Deployment](./ci-deploy.md)).
`MIREN_APP` is both: an input to the CLI, and — for a transition window — a **deprecated injected alias** of `MIREN_RUNTIME_APP` (along with `MIREN_VERSION` and `MIREN_INSTANCE_NUM`), so apps still reading the old names keep working. The two roles no longer collide: when the CLI runs inside a sandbox and the injected `MIREN_APP` matches the sandbox's own app, it ignores that value and resolves from your `.miren/app.toml`. Read the `MIREN_RUNTIME_*` names for injected runtime values; the aliases will be removed in a future release.
:::
:::warning[The MIREN_ prefix is reserved]
You can't set your own variables under `MIREN_` — `miren env set MIREN_FOO=bar` fails with `cannot set MIREN_ environment variables`. Miren owns the whole namespace so it can add injected variables without colliding with yours. Name your own `APP_*` or anything else.
:::
### Traffic Routing
For HTTP services, Miren handles routing automatically. For non-HTTP services (TCP/UDP), you can expose ports directly using the `ports` array:
```toml
[services.irc]
command = "./ircd"
[[services.irc.ports]]
port = 6667
name = "irc"
type = "tcp"
node_port = 6667
```
See [Traffic Routing](./traffic-routing.md) for the full picture — HTTP ingress, TCP/UDP routing, multi-port services, and the `PORT` environment variable.
## Complete Example
```toml
name = "myapp"
[[env]]
key = "DATABASE_URL"
value = "postgres://user:pass@postgres.app.miren:5432/myapp"
[[env]]
key = "SECRET_KEY"
required = true
sensitive = true
description = "Application secret for session signing"
[build]
version = "3.12"
[services.web]
command = "gunicorn app:app --bind 0.0.0.0:8000"
port = 8000
[services.web.concurrency]
mode = "auto"
requests_per_instance = 20
[services.worker]
command = "celery -A app worker"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
[services.postgres]
image = "postgres:16"
[[services.postgres.env]]
key = "PGDATA"
value = "/var/lib/postgresql/data/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
[[services.postgres.disks]]
name = "myapp-pgdata"
mount_path = "/var/lib/postgresql/data"
size_gb = 20
```
## Reference
For a complete field-by-field listing of every `app.toml` option, see the [app.toml Reference](./app-toml.md).
---
## app.toml Reference
Complete reference for `.miren/app.toml` — the configuration file for Miren applications.
For a guide-style introduction, see [App Configuration](./app-configuration.md).
For editor autocomplete and automated validation, use the published
[JSON Schema](/app-toml.schema.json). Editors that support TOML schemas can map
that URL to `.miren/app.toml`.
## File Structure
```toml
name = "myapp"
include = ["configs/"]
# Global environment variables
[[env]]
key = "DATABASE_URL"
value = "postgres://db.app.miren:5432/myapp"
# Build configuration
[build]
version = "3.12"
dockerfile = "Dockerfile.miren"
onbuild = ["npm run build"]
# Service definitions
[services.web]
command = "node server.js"
port = 3000
[services.web.concurrency]
mode = "auto"
requests_per_instance = 10
scale_down_delay = "15m"
shutdown_timeout = "10s"
[services.worker]
command = "node worker.js"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
shutdown_timeout = "10s"
[services.db]
image = "postgres:16"
[[services.db.disks]]
name = "pgdata"
mount_path = "/var/lib/postgresql/data"
size_gb = 20
# Task definitions
[tasks.migrate]
command = "bin/rails db:migrate"
trigger = "deploy"
[tasks.cleanup]
command = "bin/cleanup-sessions"
trigger = "schedule"
every = "6h"
# Addons
[addons.storage]
variant = "minio"
# CLI Aliases
[aliases]
console = "app run bin/rails console"
tail = "logs app -f"
```
## Top-Level Fields
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `name` | string | Application name | Inferred from directory name |
| `static` | table | Static files exported from the app build output and served directly by HTTP ingress | — |
| `include` | string[] | Extra files or directories to include in the build context | — |
| `concurrency` | int | **Legacy.** Global concurrency target. Use `[services..concurrency]` instead. | — |
| `workload_role` | string | Role for this app's sandbox [in-cluster API access](./in-cluster-api.md). Only app-scoped roles may be set here; cluster-scoped roles require an operator. | `app-readonly` |
| `web` | bool | Whether the app has a long-running web process. Set `web = false` for an app made entirely of [tasks](#tasks). | Unset — a web service is synthesized if nothing declares one, except for static-only apps and task-only apps with no services |
### `web` and the synthesized web service {#web}
If neither `app.toml` nor your `Procfile` declares a `web` service, Miren
synthesizes one from the image's entrypoint. That is the historical default and
it stays the default: leaving `web` unset never changes what your app does.
There is one exception, and it's the case where guessing would be expensive: an
app that declares [tasks](#tasks) and no services has to say which it meant. See
the validation note below.
`web = false` opts out. It's how an app that only declares tasks says it has no
long-running process at all — no web service, no route, and nothing running (or
billed for compute) between invocations.
Setting `static.dir` also opts out of the synthesized web service. The build
server exports that directory into a dedicated artifact during deployment, and
HTTP ingress serves it without mounting the image or starting a sandbox. For
example:
```toml
[static]
dir = "/app/dist"
```
This serves a frontend build written to `/app/dist`. If the app also declares a
service, requests for files that do not exist fall through to that service; a
static-only app returns 404 instead.
When no Dockerfile, image, or supported stack is detected, Miren treats the
uploaded source tree as `/app` and archives `static.dir` directly. Thus
`dir = "/app/public"` serves a repository's `public/` directory without building
an OCI image. Miren excludes its `.miren` configuration directory from a source
artifact.
:::danger[Review a source-root static directory]
`static.dir = "/app"` publishes every file uploaded from the source tree except
`.miren`. The upload honors `.gitignore`, but files such as `.env` are public if
they are not ignored. Prefer a dedicated directory such as `/app/public`.
:::
It opts out of the *synthesized* service, not of a web service you asked for. A
`web` declared in `app.toml` or named by a `web:` line in your `Procfile` is an
explicit request and still runs, `web = false` or not — so an app being migrated
keeps whatever its `Procfile` says until that line is removed. Delete the
declaration to be rid of the service.
:::note[Validation]
If an app declares tasks, declares no services, and doesn't set `web`, the build
fails rather than guessing. Without that, a task-only app would quietly acquire
a web service built from its image entrypoint and keep it running forever.
:::
## `[[env]]` — Environment Variables {#env}
Declares environment variables available to all services. Service-level `[[services..env]]` entries are merged with these.
```toml
[[env]]
key = "DATABASE_URL"
value = "postgres://db.app.miren:5432/myapp"
[[env]]
key = "SECRET_KEY"
required = true
sensitive = true
description = "Used for session signing"
[[env]]
key = "STRIPE_API_KEY"
backend = "cluster"
ref = "payments/stripe-key"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `key` | string | Variable name. **Required.** | — |
| `value` | string | Variable value | `""` |
| `backend` | string | [Secret](./secrets.md) backend to source the value from | `""` |
| `ref` | string | Reference to the secret within that backend | `""` |
| `required` | bool | Fail deploy if value is empty | `false` |
| `sensitive` | bool | Mask value in CLI output and logs | `false` |
| `description` | string | Human-readable explanation of this variable | — |
:::note[Validation]
Every env entry must have a non-empty `key`. If `required` is `true` and `value` is empty at deploy time, the deploy fails. `ref` requires `backend`, and cannot be combined with `value`.
:::
## `[build]` — Build Configuration {#build}
Controls how Miren builds your container image.
```toml
[build]
version = "3.12"
dockerfile = "Dockerfile.custom"
onbuild = ["npm run build", "npm prune --production"]
alpine_image = "alpine:3.19"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `version` | string | Language/runtime version (e.g. `"20"` for Node, `"3.12"` for Python) | Detected from project files |
| `dockerfile` | string | Path to a custom Dockerfile | Auto-detected (`Dockerfile.miren` or built-in) |
| `onbuild` | string[] | Commands to run in `/app` after the main build steps | — |
| `alpine_image` | string | Custom Alpine base image for the runtime stage | Built-in default |
### `[[build.secrets]]` — Build-time secrets {#build-secrets}
Exposes an encrypted [secret](./secrets.md) to a Dockerfile build. BuildKit keeps the value out of image layers and its own logs; your `RUN` command must not print it. Each entry is mounted by its `id`, which your Dockerfile reads with `RUN --mount=type=secret,id=`. Supported for Dockerfile builds only — declaring one on an auto-detected language stack is an error. See [Using a secret at build time](./secrets.md#using-a-secret-at-build-time) for the full contract.
```toml
[[build.secrets]]
id = "npm_token"
ref = "registry/npm-token"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `id` | string | Mount identifier used in `--mount=type=secret,id=`. Letters, digits, and `_.-` only; unique within the list | Required |
| `backend` | string | [Secret](./secrets.md) backend to resolve against | `cluster` (built-in store) |
| `ref` | string | Reference naming the secret within the backend | Required |
## `[services.]` — Service Configuration {#services}
Each named section under `services` defines a process in your app. See [Services](./services.md) for usage patterns.
```toml
[services.web]
command = "node server.js"
port = 3000
port_name = "http"
port_type = "http"
[services.postgres]
image = "postgres:16"
args = ["postgres", "-c", "shared_buffers=256MB"]
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `command` | string | Shell command that replaces the image startup command (`/bin/sh -c`) | — |
| `args` | string[] | Exec-form arguments that replace the image `CMD` while preserving its `ENTRYPOINT` | — |
| `port` | int | Port the service listens on (single-port shorthand) | For `web` inheriting the primary image: its single exposed TCP port, otherwise `3000` |
| `port_name` | string | Named port identifier (single-port shorthand) | Service name |
| `port_type` | string | `"http"` or `"tcp"` (single-port shorthand) | `"http"` |
| `ports` | [[port]](#ports) | Multi-port configuration array | — |
| `port_timeout` | duration | Time to wait for the service to bind its port at startup (e.g. `"60s"`, `"2m"`) | `"15s"` |
| `image` | string | Container image to use. On `services.web`, this selects the app's primary image unless `[build].dockerfile` is set or `Dockerfile.miren` exists | App version's primary image |
| `env` | [[env]](#env) | Service-specific environment variables (same schema as global `[[env]]`) | — |
| `concurrency` | [concurrency](#concurrency) | Scaling configuration | See defaults below |
| `metrics` | [metrics](#service-metrics) | Prometheus-compatible metrics endpoint scraped by the runtime | Disabled |
| `disks` | [[disk]](#disks) | Persistent disk attachments | — |
:::note[Validation]
You cannot set both `command` and `args`, and `args` cannot be empty. You also cannot mix the single-port fields (`port`, `port_name`, `port_type`) with the `ports` array on the same service.
:::
With neither `command` nor `args`, Miren runs the image's `ENTRYPOINT` and `CMD`
unchanged. Use `args` when the image supplies a stable executable in `ENTRYPOINT`
and only its deployment-specific arguments need to change. Argument boundaries are
preserved exactly; Miren does not perform shell expansion.
:::note[Image selection]
When `services.web.image` is set and neither `[build].dockerfile` nor `Dockerfile.miren` selects a Dockerfile, Miren launches that image directly instead of auto-detecting and building the project source. Images on other services do not suppress source detection. If detection finds no buildable source, a lone service image can act as the fallback; with several service images, set `services.web.image` to choose the primary one.
:::
### `[services..metrics]` — Service metrics {#service-metrics}
Opts a service into managed metrics scraping. Miren scrapes the service's
Prometheus-compatible endpoint in every running sandbox and forwards its
samples to your cluster's configured remote-write destination.
```toml
[services.web.metrics]
enabled = true
path = "/metrics"
port = 3000
interval = "30s"
public = false
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `enabled` | bool | Scrape every running sandbox for this service | `false` |
| `path` | string | Absolute HTTP path to scrape | `"/metrics"` |
| `port` | int | Declared HTTP or TCP service port to scrape | First HTTP port |
| `interval` | duration | Time between scrapes, with a minimum of 30 seconds | `"30s"` |
| `public` | bool | Allow the metrics path through public ingress | `false` |
Metrics do not create a port or route. The selected port must already be part
of the service configuration, and a service without an HTTP port must set
`port` explicitly. The runtime scrapes the sandbox's private overlay address.
With the default `public = false`, Miren returns 404 for the configured path at
public ingress. Setting `public = true` lets the request continue through the
route's normal authentication and middleware. It does not bypass route
protection.
### `[services..concurrency]` — Scaling {#concurrency}
Controls how many instances of a service run. See [Application Scaling](./scaling.md) for tuning guidance.
**Default for `web`:** auto mode, 10 requests per instance, 15m scale-down delay, 10s shutdown timeout.
**Default for all other services:** fixed mode, 1 instance, 10s shutdown timeout.
```toml
# Autoscaling
[services.web.concurrency]
mode = "auto"
requests_per_instance = 10
scale_down_delay = "15m"
shutdown_timeout = "10s"
# Fixed instances
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
shutdown_timeout = "10s"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `mode` | string | `"auto"` or `"fixed"` | `"auto"` for web, `"fixed"` for others |
| `requests_per_instance` | int | Target concurrent requests per instance (auto mode only) | `10` |
| `scale_down_delay` | duration | Time to wait before removing idle instances (auto mode only) | `"15m"` |
| `num_instances` | int | Exact number of instances to run (fixed mode only) | `1` |
| `shutdown_timeout` | duration | Time to wait for graceful shutdown during redeploy | `"10s"` |
:::note[Validation]
- `mode` must be `"auto"` or `"fixed"`.
- In **auto** mode: `requests_per_instance` must be non-negative, `scale_down_delay` must be a valid Go duration, and `num_instances` must not be set.
- In **fixed** mode: `num_instances` must be at least 1, and `requests_per_instance` / `scale_down_delay` must not be set.
- `shutdown_timeout` must be a valid Go duration (e.g. `"10s"`, `"30s"`).
:::
### `[[services..ports]]` — Ports {#ports}
Configures network ports for a service. Use this when a service needs multiple ports or non-HTTP protocols. See [Traffic Routing](./traffic-routing.md) for usage patterns and examples.
```toml
[[services.app.ports]]
port = 3000
name = "http"
type = "http"
[[services.app.ports]]
port = 7000
name = "data"
type = "tcp"
node_port = 7000
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `port` | int | Port your process listens on (1–65535). **Required.** | — |
| `name` | string | Unique name for this port. **Required.** | — |
| `type` | string | `"http"` for web traffic, `"tcp"` for raw TCP, `"udp"` for UDP | `"http"` |
| `node_port` | int | Port to expose on the host machine (1–65535) | (none) |
:::note[Validation]
- `port` must be between 1 and 65535.
- `name` is required and must be unique within the service.
- `type` must be `"http"`, `"tcp"`, or `"udp"`.
- Each `(port, type)` pair must be unique within the service (`"tcp"` and `"http"` share the TCP transport, so port 8080 with type `"http"` and port 8080 with type `"tcp"` conflict, but port 53 with `"tcp"` and port 53 with `"udp"` are allowed).
- `node_port` must be between 1 and 65535 and unique across the cluster.
:::
### `[[services..disks]]` — Persistent Disks {#disks}
Attaches persistent storage to a service. See [Persistent Storage](./disks.md) for local storage and Miren Disks. A SQLite database is not declared here: the [`miren-sqlite` addon](./addons.md#sqlite-is-different) attaches its own storage.
```toml
# Local storage (simple, node-local)
[[services.web.disks]]
name = "data"
provider = "local"
mount_path = "/miren/data/local"
# Miren Disk (cloud-synced, experimental)
[[services.db.disks]]
name = "pgdata"
mount_path = "/var/lib/postgresql/data"
size_gb = 20
filesystem = "ext4"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `name` | string | Unique disk name. **Required.** | — |
| `provider` | string | `"miren"` for cloud-synced disks, `"local"` for node-local storage | `"miren"` |
| `mount_path` | string | Mount point inside the container. **Required.** | — |
| `size_gb` | int | Disk size in gigabytes (required for new miren disks, ignored for local) | — |
| `filesystem` | string | `"ext4"`, `"xfs"`, or `"btrfs"` (miren disks only) | `"ext4"` |
| `read_only` | bool | Mount as read-only | `false` |
| `lease_timeout` | duration | How long to wait when acquiring the exclusive disk lease (miren disks only) | — |
| `owner` | string | Ownership for a writable miren disk: empty makes it writable by the run user, `"keep"` opts out, `"uid"`/`"uid:gid"` pins an owner | — |
:::note[Validation]
- `name` and `mount_path` are required.
- `provider` must be `"miren"` (default) or `"local"`.
- For miren disks: `filesystem` must be `ext4`, `xfs`, or `btrfs`; `size_gb` must be non-negative.
- Miren disks require `mode = "fixed"` and `num_instances = 1`.
- `lease_timeout` must be a valid Go duration (e.g. `"30s"`, `"2m"`).
:::
By default a writable miren disk is chowned to the user your container runs as,
so a non-root image can write to it without a `chown` shim. Read-only mounts and
containers that run as root are left untouched. See
[Disks](./disks.md#configuring-disks) for the ownership rules and the one-time
migration pass on large existing disks.
## `[tasks.]` — Task Configuration {#tasks}
A **task** is a command the platform knows how to run. A **service** is a
process the platform keeps up. Migrations, backfills, cleanup jobs, and one-off
batch work are tasks.
Each run of a task gets a fresh sandbox built from the app's deployed image and
config, runs one command, records an exit code, and is torn down.
```toml
# Runs once per deploy, before the new version takes traffic
[tasks.migrate]
command = "bin/rails db:migrate"
trigger = "deploy"
timeout = "10m"
# Runs on a schedule
[tasks.cleanup]
command = "bin/cleanup-sessions"
trigger = "schedule"
every = "6h"
retries = 2
# Runs only when someone asks
[tasks.reindex]
command = "bin/reindex"
timeout = "4h"
max_concurrent = 4
[[tasks.reindex.env]]
key = "BATCH_SIZE"
value = "500"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `command` | string | The command to run. An invoke can override it. | Required |
| `trigger` | string | What starts the task: `deploy`, `schedule`, or `manual` | `manual` |
| `every` | duration | Run this often. Sugar over `schedule` — see below. | — |
| `schedule` | string | Calendar expression to fire on, in systemd `OnCalendar` syntax | — |
| `timeout` | duration | Kill the run after this long and mark it timed out. `0` means unbounded. | Platform default |
| `retries` | int | Retry budget for `deploy` and `schedule` triggers | `0` |
| `max_concurrent` | int | Cap on simultaneous runs of this task | `1` |
| `[[tasks..env]]` | table[] | Environment variables for this task only. Same fields as [`[[env]]`](#env). | — |
A task always runs in the app's image and takes no `image` or `disks` of its
own. It reaches everything the app's addons expose, since those arrive as
environment variables.
### Triggers {#triggers}
| `trigger` | Starts when |
|-----------|-------------|
| `deploy` | A new version is deployed, before it takes traffic. The deploy fails if the task does. |
| `schedule` | A calendar expression comes due |
| `manual` | Only when someone asks |
Any task can be invoked by hand whatever its trigger — that's how you re-run a
migration without redeploying.
### Scheduling {#scheduling}
Two ways to say when, and they're mutually exclusive. One is required when
`trigger = "schedule"`.
**`every`** takes a duration:
```toml
every = "6h" # 00:00, 06:00, 12:00, 18:00 UTC
every = "30m" # on the hour and the half hour
every = "24h" # midnight UTC
```
Intervals are anchored to midnight UTC, not to your deploy. A daily job fires
daily no matter how often you ship.
That anchoring is why `every` only accepts durations that divide a day evenly:
values under an hour must divide 60 minutes, and values of an hour or more must
be a whole number of hours dividing 24. `every = "7h"` is rejected at build time
because there's no honest day-aligned reading of it — use `schedule` for
anything that doesn't tile.
**`schedule`** takes a systemd `OnCalendar` expression, which reads left to
right:
```toml
schedule = "Mon *-*-* 09:00:00" # Mondays at 9am
schedule = "*-*-01 00:00:00" # the first of every month
schedule = "Mon..Fri 18:00" # weekdays at 6pm
schedule = "daily" # also: hourly, weekly, monthly
```
Every numeric field accepts `*`, a value, a comma list (`0,15,30,45`), a range
(`09..17`), and a repetition (`00/6`). Everything is UTC. Cron syntax is not
accepted; `man systemd.time` documents the full grammar.
:::note[Validation]
- `command` is required.
- `trigger` must be `deploy`, `schedule`, or `manual`.
- `every` and `schedule` are mutually exclusive, and exactly one is required when `trigger = "schedule"`. Setting either without that trigger is an error rather than a silent no-op.
- `every` must divide a day evenly.
- `timeout` must be a valid Go duration and must not be negative.
- `retries` must be non-negative, and is rejected on a `manual` task — the caller decides whether to retry.
- `max_concurrent` must be at least 1.
:::
## `[addons.]` — Addons {#addons}
Configures managed backing services. The `` is the addon identifier (e.g. `miren-postgresql`). See [Addons](./addons.md) for a full guide.
When you deploy, Miren provisions declared addons and injects connection credentials as environment variables before starting your app.
```toml
[addons.miren-postgresql]
variant = "small"
version = "16"
```
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `variant` | string | Addon variant (e.g. `small`, `shared`) | Addon's default variant |
| `services` | list | Services the addon's storage attaches to. Empty means every service. Only meaningful for addons that supply storage, such as `miren-sqlite` | all services |
| `version` | string | Software version tag, or a full image reference if it contains `:` | Addon's default version |
Run `miren addon variants ` to see available variants and `miren addon list-available` to see default versions.
Addons removed from app.toml are automatically deprovisioned on the next deploy.
## `[aliases]` — CLI Aliases {#aliases}
Defines custom shortcuts for frequently-used CLI commands. When you run `miren `, it expands to the full command before execution.
```toml
[aliases]
console = "app run bin/rails console"
tail = "logs app -f"
```
With the above configuration:
- `miren console` expands to `miren app run bin/rails console`
- `miren tail` expands to `miren logs app -f`
Alias names can contain multiple words, which lets you create command namespaces:
```toml
[aliases]
"x tail" = "logs app -f"
"x console" = "app run bin/rails console"
```
Then `miren x tail` and `miren x console` work as shortcuts.
Any extra arguments you pass after the alias name are appended to the expanded command.
:::note[Validation]
- Each word in the alias name must start with a lowercase letter and contain only lowercase letters, numbers, dashes, and underscores.
- The command string must not be empty.
- Alias names must not shadow built-in commands (e.g. you cannot define an alias named `version` or `app list`).
- Aliases are expanded only once — an alias cannot reference another alias.
:::
## Duration Format
Fields marked as `duration` accept Go duration strings: a sequence of decimal numbers with unit suffixes. Valid units are `s` (seconds), `m` (minutes), `h` (hours).
Examples: `"10s"`, `"2m"`, `"1h30m"`, `"15m"`.
---
## Changelog
All notable changes to Miren Runtime will be documented in this file.
## Unreleased
*release/0.16*
---
## v0.16.2
*2026-09-29*
**Bug Fixes**
- **Servers named with `--dns-names` get a real certificate again** - Since v0.16.0, a server started with `--dns-names` under `tls-autoprovision` served the self-signed fallback certificate for its own hostname unless a route named that host exactly, a side effect of the wildcard certificate change. That also broke anything verifying the cluster's workload identity tokens, which fetch their keys from that hostname. `--dns-names` hosts now get their certificate without a route, and with a DNS-01 provider the workload identity issuer's name does too. If you can't upgrade yet, an exact route brings the certificate back: `miren route set `, then `miren route down ` if the route shouldn't serve anything. See [TLS](./tls.md#tls-settings-reference). ([#1281](https://github.com/mirendev/runtime/pull/1281))
---
## v0.16.1
*2026-09-24*
**Bug Fixes**
- **Cloud stays in sync after a server restart** - On v0.16.0, restarting a server connected to Miren Cloud (a reboot, a crash, a manual restart) could freeze cloud's view of the cluster for up to a day while the server log repeated `entity sync stream interrupted` warnings. Sync now carries on after a restart, and a rejection it can't get past retries once a minute instead of every second. ([#1262](https://github.com/mirendev/runtime/pull/1262))
---
## v0.16.0
*2026-09-22*
**Breaking Changes**
- **Wildcard subdomains need a TLS check to get certificates** - A wildcard route used to get a Let's Encrypt certificate for any subdomain a client named, and scanners noticed: one cluster had issued more than 500 certificates for names nothing served. A name that isn't itself a route now gets a real certificate only when a live ephemeral deploy has that label, or when the route's app vouches for it through a TLS check (`miren route tls-check '*.example.com' /tls-check`). Everything else gets the fallback certificate. If you serve tenants off a wildcard route, add a check endpoint that answers `200` for the names you serve, or an exact route for each. Preview deploys need nothing new. ([#1256](https://github.com/mirendev/runtime/pull/1256))
**Features**
- **Your apps and deploys in Miren Cloud** - Until now the Miren Cloud dashboard knew little more than how many workloads a cluster ran. Registered clusters on v0.16.0 now report their apps, versions, deploys, and runners to Cloud. The dashboard's new Apps view shows each app's health and running version, and its deploy history with the commit, branch, and person behind every deploy. Each cluster page gets an apps section and a list of its runners. Cloud keeps deploy history after the cluster prunes its own copy, so nothing is lost to the new local retention limit. Clusters on earlier releases don't report apps, and the dashboard tells you to upgrade them. ([#1218](https://github.com/mirendev/runtime/pull/1218), [#1230](https://github.com/mirendev/runtime/pull/1230), [#1239](https://github.com/mirendev/runtime/pull/1239))
- **Upgrade from Miren Cloud** - A cluster's page in Miren Cloud now shows when a newer release is out and upgrades the cluster with one click, running the same verified, roll-back-on-failure upgrade as `sudo miren upgrade` and following it through the restart. Automatic upgrades are there too, off until you turn them on: pick the Stable channel, which takes a release once it has proven out (usually about a week after it ships), or Latest, plus a maintenance window. Organization admins can send the outcome of every upgrade to Slack. Clusters need v0.16.0 to be upgraded from Cloud, so the upgrade to this release is the last one done by hand. See [Upgrades from Miren Cloud](./miren-cloud/upgrades.md). ([#1218](https://github.com/mirendev/runtime/pull/1218))
- **Upgrades you can walk away from, for the whole cluster** - `sudo miren upgrade` now runs as a durable operation on the host, so a dropped SSH session no longer leaves it in an unknown state. It succeeds only when a new server process reports ready on the target build, and otherwise rolls back to the previous binary and an etcd snapshot taken just before the restart. Then it upgrades your runners one at a time, stopping if the first one fails, and brings your CLI onto the same build. `miren cluster restart` and `miren cluster upgrade` do the same from any client, including for Docker installs, and `miren server operations list|show` shows what ran. For this one release, each runner needs its own `sudo miren upgrade` before the coordinator can manage it. ([#1216](https://github.com/mirendev/runtime/pull/1216), [#1217](https://github.com/mirendev/runtime/pull/1217), [#1218](https://github.com/mirendev/runtime/pull/1218), [#1238](https://github.com/mirendev/runtime/pull/1238), [#1242](https://github.com/mirendev/runtime/pull/1242))
- **Serve static sites straight from ingress** - Set `static.dir` in `app.toml` and ingress serves those files directly, with no running sandbox. A site whose files are already in the repo doesn't need an image either. See [Static sites & SPAs](./guides/static.md). ([#1251](https://github.com/mirendev/runtime/pull/1251))
- **Deploy targets checked into the repo** - `.miren/deploy.toml` names the clusters an app deploys to, so `miren deploy staging` picks one without `-C`. Targets resolve by Miren Cloud cluster ID, so the file works for teammates with different local cluster names. Manage it with `miren deploy target`. ([#1234](https://github.com/mirendev/runtime/pull/1234))
**Improvements**
- **Memory protection for the server process** - The server now runs under a memory limit of a quarter of the machine's RAM (at least 2 GB). If it ever grows past that, the kernel restarts it rather than letting it compete with the rest of the host, and `miren logs system` notes the restart on the next boot. Apps and addon databases run outside the limit and keep running through a restart. Existing installs pick it up on their next upgrade. If you set a memory limit by hand in `/etc/systemd/system/miren.service`, move it to `sudo systemctl edit miren`, or the managed limit will override it. See [Troubleshooting](./troubleshooting.md#the-server-restarted-on-its-own). ([#1210](https://github.com/mirendev/runtime/pull/1210))
- **`miren deploy` output you can script against** - Piped deploy output was full of escape codes and replayed every build step, and the version was buried in a sentence. Non-terminal output is now plain lines ending in `Version: `. `--format json` prints one result document, `--format jsonl` streams the whole deploy as events, and `--quiet` drops the progress. ([#1194](https://github.com/mirendev/runtime/pull/1194))
- **Deploys wait for their addons** - A deploy used to move on while its addon was still provisioning, so deploy tasks ran without `DATABASE_URL` and the health check timed out. Deploys now wait for each addon to become active and fail with the reason if it errors. `miren addon list` gains a STATUS column. ([#1190](https://github.com/mirendev/runtime/pull/1190))
- **Know which versions you're running** - `miren version --server` shows CLI and server side by side, and `miren doctor` warns when either is behind the latest release, with the command to fix it. The `doctor config|server|auth` subcommands are gone; plain `miren doctor` covers them. ([#1212](https://github.com/mirendev/runtime/pull/1212), [#1244](https://github.com/mirendev/runtime/pull/1244))
- **App usage over any window** - `miren top --apps` only counted sandboxes alive right now, so last week's usage for an app redeployed Tuesday only covered since Tuesday. It now reads the metrics store, which keeps a month of samples. `--series` adds a time series and `--contributors` breaks the total down per sandbox, including ones that are gone. ([#1215](https://github.com/mirendev/runtime/pull/1215))
- **Runtime health metrics ship with your app metrics** - If you send managed app metrics to a Remote Write destination, Miren's own operational metrics (control process memory and goroutines, etcd health, host usage, restarts, build version) now go with them. No new config. ([#1211](https://github.com/mirendev/runtime/pull/1211), [#1250](https://github.com/mirendev/runtime/pull/1250))
- **Upgrades bring containerd and runc along** - Upgrades kept only the `miren` binary from the release bundle, so a host stayed on the containerd and runc it was installed with. They now install the whole bundle, starting with the upgrade after this one. ([#1237](https://github.com/mirendev/runtime/pull/1237))
- **Image pull failures show up in `miren logs`** - A node that couldn't pull an image left a crash-looped deploy with no logs, and the reason only in that node's journal. It now shows up in `miren logs` and the failed deploy's output. ([#1196](https://github.com/mirendev/runtime/pull/1196))
- **Typos get a suggestion** - An unknown command or flag now names the word at fault and suggests the closest match, instead of `unexpected arguments: [lst]`. ([#1209](https://github.com/mirendev/runtime/pull/1209))
- **Deleted apps' images get cleaned up** - Images from deleted apps were never garbage collected. They're now reclaimed after 24 hours, including ones already on existing servers. ([#1224](https://github.com/mirendev/runtime/pull/1224))
- **Deployment history is bounded** - Each app now keeps its last 25 deployment records plus anything from the past 30 days, configurable under [`[deployment]`](./server-config.md#deployment). Registered clusters only prune what Miren Cloud already holds. ([#1230](https://github.com/mirendev/runtime/pull/1230))
- **Container installs match the CLI's version** - `miren server container install` defaulted to the `:latest` image. It now installs the CLI's own version, and `--version` picks another. ([#1195](https://github.com/mirendev/runtime/pull/1195))
- **The Linux binary is fully static** - It now runs on NixOS, Alpine, and other musl hosts. ([#1223](https://github.com/mirendev/runtime/pull/1223))
- **Deploys from jj workspaces record their commit** - Deploys from a jj repo without a colocated `.git` recorded no commit, branch, or author, and shipped `.jj` in the build context. Both fixed. ([#1213](https://github.com/mirendev/runtime/pull/1213))
**Security**
- **Ingress auth cookies keep their `Secure` flag** - Concurrent http and https requests through ingress auth could flip each other's `Secure` flag, and a client could strip it by sending `X-Forwarded-Proto: http` over TLS. Forwarded-scheme headers are now trusted only in `behind-proxy-http` mode. Cookie contents were encrypted throughout. ([#1245](https://github.com/mirendev/runtime/pull/1245), [#1247](https://github.com/mirendev/runtime/pull/1247))
**Bug Fixes**
- **`miren app run` returns when the command finishes** - Attached runs used to hang for up to a minute after the command exited. ([#1206](https://github.com/mirendev/runtime/pull/1206))
- **Services with UDP ports become healthy** - A service declaring a UDP port was killed on every deploy because the boot check only saw TCP listeners. Thanks [@veganstraightedge](https://github.com/veganstraightedge)! ([#1254](https://github.com/mirendev/runtime/pull/1254))
- **`miren env set` keeps commas in values** - `-e KEY=a,b,c` set three variables instead of one. It also accepts bare `KEY=VALUE` arguments now. ([#1197](https://github.com/mirendev/runtime/pull/1197))
- **Uploads survive a file growing mid-deploy** - A file being written during the upload, like a log or the deploy's own redirected output, failed the deploy with `archive/tar: write too long`. ([#1194](https://github.com/mirendev/runtime/pull/1194))
- **Downloaded CLIs report their real version** - Standalone CLI binaries since v0.14.0 reported `HEAD:` as their version. ([#1207](https://github.com/mirendev/runtime/pull/1207))
- **Long-lived sandboxes recover workload identity** - A sandbox that lost its token registration got `403 invalid token` until restarted. It now recovers on its next retry. ([#1255](https://github.com/mirendev/runtime/pull/1255))
- **The server starts after a crash mid-shutdown** - A leftover containerd task record could stop etcd or buildkit from starting on every later boot. ([#1228](https://github.com/mirendev/runtime/pull/1228))
- **Old `/usr/local/bin/miren` launchers get repaired** - A host whose launcher predated the managed symlink kept an old CLI through every upgrade. Startup now fixes the link. ([#1246](https://github.com/mirendev/runtime/pull/1246))
- **Build traces reach HTTP-only OTLP collectors** - With `OTEL_EXPORTER_OTLP_ENDPOINT` set, buildkit tried gRPC and every build's spans were dropped. ([#1248](https://github.com/mirendev/runtime/pull/1248))
- **`miren server install` works without `HOME`** - Under cloud-init it finished the install but failed to write the local client config. ([#1252](https://github.com/mirendev/runtime/pull/1252))
---
## v0.15.2
*2026-09-16*
**Bug Fixes**
- **Bundled services keep logging after a server restart** - When Miren adopted an existing etcd, VictoriaMetrics, or VictoriaLogs process after an unclean exit, it did not reattach readers to the process's stdout and stderr pipes. Enough output could fill a pipe and block the service indefinitely. Adoption now resumes draining both streams, and the etcd maintenance loop bounds its health check so a wedged etcd shows up as a warning in the logs instead of the loop going silent. ([#1232](https://github.com/mirendev/runtime/pull/1232))
- **Streaming RPC callbacks no longer panic when their stream tears** - A streaming call that ended between a frame's header and its payload handed the receiving callback a nil value, which crashed the server from inside the entity index watcher. Streaming callbacks now treat a frame with no value as the stream closing and reconnect through their normal path. ([#1233](https://github.com/mirendev/runtime/pull/1233))
---
## v0.15.1
*2026-09-11*
**Bug Fixes**
- **Stop control-plane memory growth during watch resyncs** - Periodic controller watches left streaming connections alive after each resync, accumulating goroutines and memory until the host could run out. Streaming calls now close their connections when they finish, and closed connections and sessions are released from bookkeeping. This patch keeps v0.15's transport protocol and QUIC version. ([#1202](https://github.com/mirendev/runtime/pull/1202))
---
## v0.15.0
*2026-09-09*
**Breaking Changes**
- **`post_import` is no longer a valid app.toml field** - It was still parsed and validated, but nothing in the runtime read it, and the docs stopped mentioning it a while back. Because unknown keys are a parse error, an `app.toml` that still carries a `post_import` line now fails validation rather than being ignored. Delete the line; if you need work to run on deploy, use a [deploy task](./tasks.md). ([#1091](https://github.com/mirendev/runtime/pull/1091))
- **`miren app history` drops `--status` and `--hide-failed`** - Their mixed attempt/serving semantics interacted badly with `--limit`, so the results weren't reliable. Use `--all --format json` and filter downstream; the RPC-level filter stays for compatibility. ([#1111](https://github.com/mirendev/runtime/pull/1111))
**Security**
- **`miren sandbox list --json` no longer prints resolved secrets** - It printed raw sandbox entities, which include the resolved container environment. Listing now goes through its own RPC with an allowlisted response, and both table and JSON output use it. A new CLI falls back to raw entities only when it can tell the server is too old to have the new call. ([#1154](https://github.com/mirendev/runtime/pull/1154))
**Features**
- **See what's using your cluster's CPU and memory** - Diagnosing a busy cluster meant listing sandboxes, then dialing runners one at a time for a raw dump of lifetime counters, with nothing aggregated and no way to tell an app's load from Miren's own. `miren top` lists sandboxes busiest-first, `--apps` totals each app across everything it owns, and `--nodes` shows which machine is hot and how much of that is platform overhead. Every runner already ships its metrics to one store, so the view covers the whole cluster and can look back in time: `--since 1h --aggregate max` finds the spike that paged you an hour ago. `miren sandbox inspect` picks up from there with what one sandbox runs, how it exited, and whether its pool has stopped replacing it. ([#1153](https://github.com/mirendev/runtime/pull/1153))
- **Route a hostname to a service other than `web`** - An HTTP route always reached the app's `web` service, so an app with a separately named API or dashboard service had no way to put a hostname in front of it. `miren route set --service api` now selects any HTTP-capable named service; leaving the flag off still means `web`. ([#1095](https://github.com/mirendev/runtime/pull/1095))
- **Unattended cluster enrollment** - Registering a cluster meant a human approving in a browser, which doesn't work for a machine booting itself from cloud-init. `miren server install --enroll-token ` takes a single-use token from Miren Cloud and saves an approved registration in one shot. A token cloud rejects is terminal on purpose, so an unattended machine doesn't sit waiting for an approval nobody knows to give. ([#1102](https://github.com/mirendev/runtime/pull/1102))
- **Scriptable `miren cluster add`** - Scripting `miren cluster add` meant passing `--address` yourself, which skipped the useful part: trying each address the cluster advertises, asking Miren Cloud to relay when none answer, and saving the cluster's certificate to check later connections against. `--cluster prod` on its own now does all of that, `miren cluster available` lists the names, and `--format json` reports the outcome with a code you can branch on. ([#1118](https://github.com/mirendev/runtime/pull/1118))
- **Managed application metrics** - A service can opt into managed metrics without exposing its endpoint publicly. The coordinator finds each running sandbox, scrapes it privately, and sends samples to your Prometheus Remote Write destination with Miren's own app, version, service, sandbox, runner, and cluster labels attached. Scrape and delivery failures show up in `miren logs system vmagent`. ([#1087](https://github.com/mirendev/runtime/pull/1087))
**Improvements**
- **Less config when bringing your own image** - Running software someone else packaged meant repeating what the image already knew: a wrapper Dockerfile to clear an entrypoint, a `command` to put it back, a `port` the image already declared. `services.web.image` is now a complete build source on its own, inheriting the image's entrypoint, working directory, and single exposed TCP port, and pinning the deploy by digest. Set `args` to keep the image's `ENTRYPOINT` with a different `CMD`, and `app status` names where a version came from. Explicit config still wins. ([#1090](https://github.com/mirendev/runtime/pull/1090), [#1099](https://github.com/mirendev/runtime/pull/1099), [#1108](https://github.com/mirendev/runtime/pull/1108), [#1110](https://github.com/mirendev/runtime/pull/1110), [#1106](https://github.com/mirendev/runtime/pull/1106))
- **Builds keep running when the CLI goes away** - Cancellation rode on the CLI's RPC connection, so closing a terminal stopped the build, and `miren deploy cancel` worked only because the initiating CLI noticed and cancelled locally. The server-owned deployment record is the control plane now, so a build outlives the CLI that started it, and Ctrl-C writes an explicit cancellation that unwinds cleanly. ([#1112](https://github.com/mirendev/runtime/pull/1112), [#1094](https://github.com/mirendev/runtime/pull/1094))
- **One deployment shows as active** - Legacy rows kept an `active` status after another deploy took over, so `miren app history` could show several active deployments at once even though the app served one. The app's serving pointer decides now; everything else reads `succeeded`. ([#1111](https://github.com/mirendev/runtime/pull/1111))
- **`miren app runs` is easier to scan** - Runs now show short ids the way `app history` and `app status` already do, and sort by start time, newest first. The old sort pulled failures to the front, so the position of the run you just triggered depended on how unrelated older runs ended. `--format json` still emits full ids. ([#1092](https://github.com/mirendev/runtime/pull/1092))
- **Finished sagas no longer hold up startup** - A saga is saved on every status change, and the entity index kept the old value each time, so an execution stayed listed under every status it had passed through. Recovery then read the whole set to find the few it owned, which on a 4 GB host could stall startup before ingress came up. The backlog is now swept in the background, and recovery and retention read a page at a time. ([#1166](https://github.com/mirendev/runtime/pull/1166), [#1167](https://github.com/mirendev/runtime/pull/1167))
- **Builds resolve DNS through Miren's own resolver** - v0.14 stopped the builder hardcoding public DNS, but a host using the systemd-resolved stub (`nameserver 127.0.0.53`) still ended up on `8.8.8.8`, because BuildKit strips loopback nameservers and substitutes its own. Builds now point at Miren's bridge resolver, which already serves sandboxes and forwards to the host's real upstream. ([#1103](https://github.com/mirendev/runtime/pull/1103))
- **Sandbox DNS failures say what happened** - A failed lookup returned a bare SERVFAIL, and glibc could spend ten seconds waiting for it. Resolvers now work within one 1.5-second budget for the whole request and log the query, client, transport, and upstream that failed. The bridge resolver serves UDP and TCP on both IPv4 and IPv6, and retries a truncated reply over TCP. ([#1115](https://github.com/mirendev/runtime/pull/1115))
- **Server components start in parallel when they can** - Startup was one long ordered function, with dependencies expressed through line placement and sleeps. Coordinators and runners now start independent components at the same time and wait only where something genuinely depends on something else, with shutdown following the same order in reverse. Distributed runners work the same way. ([#1100](https://github.com/mirendev/runtime/pull/1100), [#1158](https://github.com/mirendev/runtime/pull/1158), [#1161](https://github.com/mirendev/runtime/pull/1161))
**Bug Fixes**
- **BuildKit restarts cleanly** - Stopping Miren while BuildKit was still shutting down could leave containerd's task behind, and the next boot would fail with `task miren-buildkit: already exists`. Evicting that task is a precondition for startup now, and cleanup waits for the process to exit before deleting it. ([#1148](https://github.com/mirendev/runtime/pull/1148))
- **Idle apps stay idle** - The deployment launcher treated a reused pool at zero instances as a fresh deploy, so the minutely resync kept cold-starting scale-to-zero autoscale apps and waiting for them to become ready. Scale-to-zero pools now stay drained unless the pool is actually moving to a new version. ([#1150](https://github.com/mirendev/runtime/pull/1150))
- **Deploys don't wait on a removed node** - A disk-aware deploy could stall when a historical, diskless sandbox stayed STOPPED on a node that no longer existed, because each resync rewrote the decommissioned pool and waited out the same timeout. Terminal sandboxes whose node is gone are now collected, so old records stop holding pools open. ([#1162](https://github.com/mirendev/runtime/pull/1162))
- **Routine client disconnects stop printing stack dumps** - The reverse proxy uses an abort signal as ordinary control flow when a response stream can't continue, and httpingress logged every recovered panic before passing it back, so a client hanging up mid-response appeared as an Error-level stack dump. ([#1138](https://github.com/mirendev/runtime/pull/1138))
- **Requests to addon-backed services stop timing out** - An app with a `[addons.miren-postgresql]` addon could deploy and report healthy while every request touching the database hung at the ingress. On kernels built without `CONFIG_NFT_OBJREF`, the named traffic counter in every service chain was rejected, which threw out the whole batch of rules, and our cache then recorded rules the kernel never took, so the next pass saw nothing to do. Service chains now use an anonymous counter, which works on every kernel, and a rejected batch clears the cache. One visible change: the counter is per service and resets on the periodic rebuild, so it reads as a rolling window rather than a running total. ([#1165](https://github.com/mirendev/runtime/pull/1165), [#1180](https://github.com/mirendev/runtime/pull/1180))
- **HTTP metrics no longer grow without bound** - The ingress kept a counter for every distinct URL path it ever served and never evicted one, so an app with IDs in its routes, or a client walking random URLs, grew the server's memory and its metrics series indefinitely. In one v0.14.0 profile those counters were most of the live heap. Paths are now capped at 100 tracked entries per node, with the rest counted under `other`, and the busiest paths earn their slots over time rather than first-come. `miren app status` reads its top-paths view from those counters directly, so per-path error rates and latencies stay consistent with each other. ([#1188](https://github.com/mirendev/runtime/pull/1188))
- **Stuck saga records from older versions get cleared** - Some of the runtime's internal workflow records (sagas) written by older versions could never finish and never be deleted: a retry finds its earlier attempt by name, but one with a generated name has nobody left who can name it, and retention only removes finished work. Each one stayed in the store forever and every recovery pass paid to read it; one pre-upgrade snapshot held nearly 20,000 for a single addon. A background sweep now marks anything untouched for seven days as failed, which lets the existing rules retry or reap it. Setting `saga.retention_period` to 0 still means leave saga records alone, and now stops this sweep too. ([#1185](https://github.com/mirendev/runtime/pull/1185))
- **Controllers don't drop or replay stale work under load** - A busy controller queued whole event snapshots in a bounded channel, so it could act on stale state, drop an event, or sit waiting for another change after a transient failure. Each entity now gets one queue entry, is re-read immediately before reconciling, and retries on its own bounded schedule. ([#1164](https://github.com/mirendev/runtime/pull/1164))
- **A slow-starting image isn't torn down once its port is up** - The port wait raced its success signal against its deadline, so a sandbox could occasionally be marked dead with `nothing is listening on :N` just as the port came up. The wait re-checks before reporting a timeout. The next pool retry usually recovered, so this showed up as an intermittent boot failure. ([#1133](https://github.com/mirendev/runtime/pull/1133))
- **`miren login` waits out a transient outage** - The device-login poll treated any non-JSON response from the token endpoint as fatal, so a proxy returning a 502 HTML page during a brief outage ended the login with `failed to parse response: invalid character '<'`. A non-200 response keeps polling within the existing ten-minute window; a 200 with a non-JSON body is still an error. ([#1136](https://github.com/mirendev/runtime/pull/1136))
- **A failed disk restore doesn't leave a volume behind** - Cleanup deleted the disk entity directly, which skips the path that reaps its volume, so a restore that failed partway could leave the volume behind along with its mount and, where cloud is configured, a volume on the cloud side. Restore now orders its writes so a failure leaves nothing, and cleanup goes through the normal deletion path. ([#1132](https://github.com/mirendev/runtime/pull/1132))
- **Streaming RPCs reconnect after a server disappears** - A watch could stop reconnecting for the life of the process when the remote went away mid-handshake: the caller's cancellation never reached the wait for HTTP/3 settings, so the retry loop had nothing to retry on. Both phases of the dial are bounded by the caller's context now. ([#1119](https://github.com/mirendev/runtime/pull/1119), [#1143](https://github.com/mirendev/runtime/pull/1143))
---
## v0.14.0
*2026-08-27*
**Features**
- **Secrets** - Store credentials in a built-in cluster backend with `miren secret`, then reference them from `app.toml` or `miren env set --backend cluster --ref db/url`. Values are encrypted at rest and materialized into the container without being written to config or etcd. References pin to a version so rollbacks restore the old value. Dockerfile builds can also consume declared `[[build.secrets]]` through BuildKit secret mounts, keeping them out of image layers and logs. See the [secrets guide](./secrets.md). ([#1027](https://github.com/mirendev/runtime/pull/1027), [#1028](https://github.com/mirendev/runtime/pull/1028), [#1031](https://github.com/mirendev/runtime/pull/1031), [#1078](https://github.com/mirendev/runtime/pull/1078))
- **App Tasks** - Declare an entry under `tasks` like `[tasks.migrate]` to create a command and trigger to run work on deploy, on a systemd-calendar schedule, or by hand. Deploy tasks gate the version flip on success. Runs keep durable exit codes and logs, and `miren app runs` lets you inspect or reattach to them. Task-only apps can set `web = false`. See the [tasks guide](./tasks.md). ([#1019](https://github.com/mirendev/runtime/pull/1019), [#1043](https://github.com/mirendev/runtime/pull/1043), [#1045](https://github.com/mirendev/runtime/pull/1045), [#1080](https://github.com/mirendev/runtime/pull/1080))
- **Call the cluster API from inside your app** - Sandboxed code can now call the cluster API using its workload identity. `miren` works inside a container with no setup, under an app-scoped read-only role by default. Set `workload_role` in `app.toml` for another app role, or use `m app set-workload-role` for a cluster-wide role. See the [in-cluster API guide](./in-cluster-api.md). ([#1018](https://github.com/mirendev/runtime/pull/1018))
- **SQLite as an addon** - Add `[addons.miren-sqlite]` to give an app a SQLite database in its own container, replicated to the coordinator and restored when the app moves to a new node. Apps read `DATABASE_URL`, and Miren requires each service using the single-writer database to run one fixed instance. This one's early, so kick the tires and tell us what breaks. ([#1051](https://github.com/mirendev/runtime/pull/1051))
- **Per-route request timeouts** - One slow route used to be everyone's problem: to keep a long-poll or a streaming endpoint from getting cut off, you had to raise the cluster-wide request timeout for every app. Now a route carries its own: `miren route timeout longpoll.example.com 10m`, or `--clear` to fall back to the cluster default. ([#1057](https://github.com/mirendev/runtime/pull/1057))
- **Maintenance mode for routes** - `miren route down app.example.com --reason "Upgrading the database"` takes a hostname out of service and shows visitors a holding page. It returns a 503 with an optional `Retry-After`, while the app keeps running for commands and one-shot migrations. `miren route up` restores traffic. See the [maintenance mode guide](./maintenance-mode.md). ([#1066](https://github.com/mirendev/runtime/pull/1066))
- **Reach a cluster behind NAT through Miren Cloud** - A cluster behind NAT or on a home network — anything without a public route — was awkward to run `miren` against. Now you can reach it over the authenticated connection it already holds up to Miren Cloud: `miren cluster add --via-cloud`. Your credentials ride end-to-end inside the request; cloud only relays the bytes and can't read them. ([#979](https://github.com/mirendev/runtime/pull/979), [#1056](https://github.com/mirendev/runtime/pull/1056))
- **Back up disks to Miren Cloud** - Disks with `provider = "miren"` can now back up to Miren Cloud. Universal-mode disks take an on-demand snapshot with `miren disk backup --cloud` (not automatic — reading a live-mounted disk isn't crash-consistent, so you pick the moment). Accelerator-mode continuous backup, which had a bug that kept it from ever working, now works. ([#1049](https://github.com/mirendev/runtime/pull/1049))
- **Disconnect a cluster from Miren Cloud** - `miren server unregister` is the clean inverse of `register`: it revokes the cluster's cloud credentials, clears the local registration, and restarts. Pass `--local-only` to forget it locally without contacting cloud. ([#1021](https://github.com/mirendev/runtime/pull/1021))
- **Move where workload identity is anchored** - New clusters now anchor workload-identity token issuance at miren.cloud by default — cloud serves the public discovery endpoint on the cluster's behalf, while the signing key never leaves your box. Existing clusters move with `miren server identity-anchor cloud` (or `cluster` to go back), and tokens issued mid-switch keep working through an overlap window. ([#1052](https://github.com/mirendev/runtime/pull/1052))
**Improvements**
- **Builds and sandbox startup survive a restart** - Both now use the saga engine by default, journaling each step so a control-plane restart can resume or unwind work instead of stranding it. The previous path remains as an escape hatch for one release: start the server with `--labs -sagas` or `MIREN_LABS=-sagas`. See [Miren Labs](./labs.md). ([#972](https://github.com/mirendev/runtime/pull/972))
- **Next.js builds** - Deploy a Next.js app with no Dockerfile and Miren now detects it, runs `next build`, and serves it with `next start`. Before, an auto-built Next.js app shipped without its compiled output and crashed on boot. ([#980](https://github.com/mirendev/runtime/pull/980))
- **app.toml editor support** - Miren now publishes a JSON Schema for app.toml, so an editor that understands it can validate your config and autocomplete fields as you type. Linked from the app.toml reference. ([#1070](https://github.com/mirendev/runtime/pull/1070), [#1075](https://github.com/mirendev/runtime/pull/1075))
- **`sandbox exec` finds the sandbox for you** - `miren sandbox exec --app myapp -- ` picks a running sandbox instead of making you list them, find a RUNNING row, and copy an ID. `sandbox list` takes the same `--app`/`--service` filters. ([#1058](https://github.com/mirendev/runtime/pull/1058))
- **Clearer debug output** - `miren debug entity` was rebuilt around readable cards and tables and no longer chokes on large kinds. `miren debug saga` lists saga executions and shows each one's timing, errors, and undo state without decoding base64 by hand. `miren debug etcdctl` talks to the embedded etcd with the endpoint and credentials filled in for you. ([#1030](https://github.com/mirendev/runtime/pull/1030), [#1026](https://github.com/mirendev/runtime/pull/1026), [#1023](https://github.com/mirendev/runtime/pull/1023))
- **A warning when local disks share a directory** - Two `provider = "local"` disks at different mount paths silently pointed at the same host directory. Miren now warns at deploy time and names the paths, before the collision corrupts data. ([#1065](https://github.com/mirendev/runtime/pull/1065), [#1084](https://github.com/mirendev/runtime/pull/1084))
- **`server install` checks its dependencies** - `miren server install` and `miren runner install` now verify `iptables` and `nft` are present and tell you how to install them for your distro, instead of failing quietly at runtime when networking doesn't come up. ([#1067](https://github.com/mirendev/runtime/pull/1067))
- **Finished sagas get cleaned up** - Now that sagas run by default, terminal executions no longer pile up forever — they're swept after 7 days, tunable with `saga.retention_period`. ([#1022](https://github.com/mirendev/runtime/pull/1022))
**Bug Fixes**
- **Addon variables survive rollback and restart** - An addon's variables (like `DATABASE_URL`) were copied into a config version once and never updated, so deploying, rolling back, or restarting an older version handed the app a config with the variable missing — recoverable only by destroying the addon, which dropped the database. Bindings now resolve live, so the variable is always there. ([#1063](https://github.com/mirendev/runtime/pull/1063))
- **Addons recover after a coordinator crash** - Killing a coordinator partway through provisioning an addon left it stuck "provisioning" forever, with the app up but never actually running. Recovery now finishes the job. ([#1048](https://github.com/mirendev/runtime/pull/1048))
- **Disk handoff waits for the old process to let go** - On a disk-backed deploy, a replacement could start against the same local disk while the retiring process still held it — once observed deleting a file its replacement had just written. Miren now waits for the old sandboxes to fully exit first. ([#1077](https://github.com/mirendev/runtime/pull/1077))
- **Rolling back to a specific version works again** - `miren app rollback` to a canonical version ID came back "missing" because the lookup mangled the ID. It now accepts bare, short, and full references. ([#1076](https://github.com/mirendev/runtime/pull/1076))
- **Auto-builds keep the base image's environment** - An app built without a Dockerfile lost every environment variable its base image set, which broke Bun (its recent images move `node` onto a `PATH` the base image sets). Auto-builds now inherit that environment. ([#1079](https://github.com/mirendev/runtime/pull/1079))
- **Builds respect your network's DNS** - The builder hardcoded public DNS servers, so builds on a network with an internal resolver — split-horizon DNS, a private mirror — couldn't resolve internal names. It now uses the host's DNS configuration. ([#1083](https://github.com/mirendev/runtime/pull/1083))
- **Wildcard routes fall back to the active version** - A wildcard route (`*.app.example.com`) read the subdomain as a version label and returned 404 when it wasn't one, so it couldn't serve tenants off your active version. Only wildcard routes now fall back; exact routes stay strict. ([#1068](https://github.com/mirendev/runtime/pull/1068))
- **`cluster add` writes a config that connects** - Adding a cluster by hand wrote an entry with no identity, so the next command failed with a `tls: failed to find any PEM data` error far from the real cause. It now records the credentials from your login, and `cluster remove` can remove the active cluster. ([#1042](https://github.com/mirendev/runtime/pull/1042))
- **Accelerator disk backups keep running** - An accelerator-mode disk stopped backing up the moment it acquired its cloud lease, because of a mismatched volume ID, then retried the rejected upload forever. Fixed. ([#1074](https://github.com/mirendev/runtime/pull/1074))
- **`miren logs` exits without a scary error** - A finished `miren logs` run could print all its records, succeed, and still leave a `stream reset` error on the terminal from a harmless shutdown race. That's gone. ([#1050](https://github.com/mirendev/runtime/pull/1050))
- **Connect notice stays out of piped output** - The "connecting to…" notice went to stderr even when you were piping `--format json 2>&1` into another tool, corrupting the stream. It's now suppressed when the output isn't a terminal. ([#1053](https://github.com/mirendev/runtime/pull/1053))
- **Networking recovers after an etcd compaction** - A per-node lease watch could get stuck after etcd compacted its history, spamming logs and quietly disabling lease-revocation detection until the process restarted. Picked up the upstream flannel fix. ([#1035](https://github.com/mirendev/runtime/pull/1035))
**Documentation**
- **Minimum working examples on every feature page** - Every feature page now opens with a copy-pasteable minimal example, so you can get something working before reading the reference. ([#1046](https://github.com/mirendev/runtime/pull/1046))
- **Raw Markdown for docs pages** - Every docs page is now served as raw Markdown at its `.md` URL, friendlier for agents and LLMs reading the docs. ([#1071](https://github.com/mirendev/runtime/pull/1071))
- **Self-hosting headscale** - A new recipe walks through running headscale (an open-source Tailscale control server) on Miren. ([#1054](https://github.com/mirendev/runtime/pull/1054))
---
## v0.13.0
*2026-08-04*
**Breaking Changes**
- **VictoriaMetrics and VictoriaLogs now listen on loopback only** - Neither authenticates anybody, so reachability was the entire access control. If you were scraping either directly from another host, that stops working. ([#998](https://github.com/mirendev/runtime/pull/998))
- **The cluster-local registry now requires authentication** - A cluster with no hostname used to get no workload identity issuer, and ran an open registry as a result. Every cluster gets an issuer now and the registry is authenticated everywhere; Miren mints the tokens itself, so there's nothing to configure. ([#990](https://github.com/mirendev/runtime/pull/990), [#991](https://github.com/mirendev/runtime/pull/991), [#994](https://github.com/mirendev/runtime/pull/994))
**Features**
- **Distributed runners** - One machine eventually runs out of room, so now you can add another: `miren runner install` joins a node to your coordinator and Miren schedules sandboxes across the cluster, with node-to-node traffic on an encrypted WireGuard overlay. On by default after baking in labs, with `MIREN_LABS=-distributedrunners` as the escape hatch for one release. See the [distributed runners guide](./distributed-runners.md). ([#971](https://github.com/mirendev/runtime/pull/971), [#983](https://github.com/mirendev/runtime/pull/983), [#985](https://github.com/mirendev/runtime/pull/985), [#987](https://github.com/mirendev/runtime/pull/987), [#989](https://github.com/mirendev/runtime/pull/989), [#992](https://github.com/mirendev/runtime/pull/992), [#1008](https://github.com/mirendev/runtime/pull/1008))
- **Running Miren on a Tailscale-only host** - A tailnet-only cluster failed every connection with a bare timeout, because Miren dropped tailnet addresses from what a cluster advertises and sent QUIC handshakes too large for a 1280-MTU link. Both are fixed, and there's a [guide for running on a tailnet](./tailscale.md). ([#1000](https://github.com/mirendev/runtime/pull/1000), [#1001](https://github.com/mirendev/runtime/pull/1001))
**Security**
- **Safer archive extraction for build contexts and releases** - Build contexts reach the extractor before BuildKit can isolate them, and it trusted every archive entry name. Extraction and the release downloader now check containment and refuse to write through existing symlinks. ([#988](https://github.com/mirendev/runtime/pull/988), [#1005](https://github.com/mirendev/runtime/pull/1005))
- **Password-protected routes reject off-site redirects** - The return-path check rejected `//evil.com` but let `/\evil.com` through, which browsers normalize to the same thing and follow off-site. ([#1006](https://github.com/mirendev/runtime/pull/1006))
- **Workload identity subjects are unambiguous** - Subject claims joined labels and values with `:` while still allowing that character inside the values, so two distinct workloads could collapse to one federation identity. ([#1010](https://github.com/mirendev/runtime/pull/1010))
**Improvements**
- **Deployments are tracked by the server** - The CLI used to own the deployment record, so a client that crashed could strand one, and the lock against concurrent deploys was a list-then-create with a 30-minute timeout. The servers own it now, behind a real state machine and a lock two racing deploys cannot both win. ([#975](https://github.com/mirendev/runtime/pull/975))
- **Clearer errors when a cluster is unreachable** - `timeout: no recent network activity` was the answer to nearly every problem, and it usually just meant the server wasn't running. Miren tells "not running", "too old to answer this", and "healthy but doesn't have what you asked for" apart now, and `miren doctor` gives one verdict instead of separate probes that disagree. ([#996](https://github.com/mirendev/runtime/pull/996))
- **Coordinators and runners log less** - Every systemd-installed coordinator ran at Debug indefinitely because `server install` defaulted to `-vv`, and the audit trail spent about half its bytes re-recording unchanged certificate details on every RPC. Daemons start at Info now and those records are deduplicated, cutting a coordinator's output by roughly two thirds. ([#1009](https://github.com/mirendev/runtime/pull/1009))
- **Labs features report their status at startup** - A feature that defaults to on used to say nothing at boot, which is the state an operator most wants confirmed. Every boot now logs where each labs feature ended up, and [Miren Labs](./labs.md) documents the `-` prefix (`MIREN_LABS=-distributedrunners`) that turns one back off. ([#1012](https://github.com/mirendev/runtime/pull/1012))
**Bug Fixes**
- **Deploys keep serving traffic for apps with mixed services** - The shim that keeps legacy local storage mounted probed at the app level but injected a disk into every service, so a stateless web service sitting beside a Valkey service took the drain-then-create deploy path and left a few seconds with nothing listening. ([#997](https://github.com/mirendev/runtime/pull/997))
- **Sandboxes on reused addresses receive workload identity tokens** - The token server maps a caller's source address to an app and never corrected a stale entry, so a sandbox that landed on a recently recycled address got `403 invalid token` for its entire life. ([#1003](https://github.com/mirendev/runtime/pull/1003))
- **Sandbox metrics continue across a restart** - Sandboxes that outlive a restart get adopted, but their cgroup metrics were never re-registered, so CPU and memory stopped at the restart while logs kept flowing and everything looked healthy. ([#999](https://github.com/mirendev/runtime/pull/999))
- **Schema reindexing completes on large clusters** - The reindex was capped by a two-minute startup deadline and restarted from the first entity on every boot, so a large cluster never finished one and entities could stay missing their index entries. It's a resumable background job now. ([#1004](https://github.com/mirendev/runtime/pull/1004))
- **GitHub autodeploy works for renamed and newly created repositories** - GitHub now bakes numeric IDs into the OIDC subject claim, and `miren auth ci add --github` built its match pattern from the owner and repo names, so bindings were inert from the moment you created them. They match on the `repository` claims now. Upgrade servers before CLIs. ([#995](https://github.com/mirendev/runtime/pull/995))
- **Sandbox tokens are released when the sandbox stops** - Token state was released on entity delete, which the normal exit path never reaches, so the refresher kept minting a fresh JWT every 45 minutes for sandboxes that had exited long ago. ([#1002](https://github.com/mirendev/runtime/pull/1002))
- **Disk undelete waits for the volume to mount** - `miren disk undelete` reported the disk provisioned before the runner had mounted the volume, so a lease created in that window stayed failed forever. ([#970](https://github.com/mirendev/runtime/pull/970))
---
## v0.12.1
*2026-07-24*
**Security**
- **Fixed an unauthenticated path traversal in the cluster-local OCI registry** ([GHSA-p37g-hvg3-r6wr](https://github.com/mirendev/runtime/security/advisories/GHSA-p37g-hvg3-r6wr)) - The registry on `:5000` accepted unauthenticated blob uploads and joined the client-supplied digest onto a filesystem path without checking it, so a traversing digest wrote attacker-controlled content anywhere on the host, as root. Paths are now validated before they reach the filesystem, and uploads are verified against the digest they claim. Port 5000 isn't among the inbound ports our firewall docs ask you to open, but it's open to every container on the node by design. Upgrade and restart the server to pick up the fix.
**Improvements**
- **Rotate an addon's credentials with `miren addon rotate`** - Changing a database password meant manual `ALTER` surgery on the running engine and remembering to redeploy everything that connected to it. `miren addon rotate ` changes the credential, records the new secret, and redeploys the apps that use it. Covers Valkey, PostgreSQL and MySQL (shared and dedicated), and RabbitMQ, for both the per-app user and the admin credential. Rotations are idempotent and crash-recoverable, and only one runs per addon at a time. ([#948](https://github.com/mirendev/runtime/pull/948), [#952](https://github.com/mirendev/runtime/pull/952), [#953](https://github.com/mirendev/runtime/pull/953), [#956](https://github.com/mirendev/runtime/pull/956))
- **`miren login` stops registering a key every time** - Each login minted a fresh ed25519 keypair and registered it with the cloud, a lot of permanent server-side state for a routine sign-in. Login now keeps the tokens it already receives and renews them in the background. `--persistent-key` opts back into the old behavior, and an identity that already has a key keeps it. Logging out revokes the refresh token, so a copied config stops renewing. ([#968](https://github.com/mirendev/runtime/pull/968))
- **The `miren` on your `$PATH` keeps up with the server** - The install docs put `miren` at `/usr/local/bin/miren`, but `miren server upgrade` only advanced the managed binary, so the CLI you were typing quietly drifted from the server it talked to. `server install` and `server upgrade` now keep that path pointed at the managed release, tracking upgrades and rollbacks on their own. Existing boxes heal on the first upgrade after this ships, and a path something else owns is left alone. ([#969](https://github.com/mirendev/runtime/pull/969))
- **An image with no command runs its own entrypoint** - A Dockerfile build with no service command used to flatten the image's `ENTRYPOINT` and `CMD` into a shell string and run it under `/bin/sh -c`, which is where PID 1 and signal forwarding go subtly wrong. The entrypoint now runs directly in exec form, the way `docker run` behaves. Giving a `command` is unchanged. ([#951](https://github.com/mirendev/runtime/pull/951))
**Bug Fixes**
- **Two addons on the same app both get their variables** - Attach a second addon and only one reached the app. The other went `active` with its variables sitting on the association, but they never appeared in `miren env list` or the running process, so nothing looked wrong until the app 500'd. Both reconciled at once and the second write overwrote the first. Those writes are guarded now, and a losing write retries on top of the winner so the two sets compose. The same guard covers the env-var and configuration paths. ([#954](https://github.com/mirendev/runtime/pull/954), [#957](https://github.com/mirendev/runtime/pull/957))
- **`miren app history` shows deploys from CI** - Deploys from a laptop showed up, deploys through CI didn't, and `miren app versions` told the correct story the whole time. A manual deploy and a CI deploy identify the cluster differently, by name and by address, and history filtered on an exact match. Each cluster's store only ever holds its own deployments, so the filter is gone. ([#966](https://github.com/mirendev/runtime/pull/966))
- **Dockerfile builds pick up their image's `WORKDIR`** - Miren read a built image's config back through an in-cluster registry name the coordinator can't resolve, so the lookup failed on every Dockerfile build and `WorkingDir` silently fell back to `/app`. The config now comes straight from the build. ([#955](https://github.com/mirendev/runtime/pull/955))
**Documentation**
- Corrected the glossary's claim that sandboxes are isolated with gVisor. They run under runc, isolated by kernel namespaces and cgroups. ([#973](https://github.com/mirendev/runtime/pull/973))
---
## v0.12.0
*2026-07-21*
**Breaking Changes**
- **LSVD disk volumes are no longer supported** - The legacy LSVD/NBD disk backend has been removed entirely, along with the one-time migration that converted LSVD volumes to the Universal (loop-device) format. That automatic migration shipped in **v0.6.0** and has run on first boot of every release since. If a node still holds un-migrated LSVD volume data when it reaches this release, that data can no longer be converted and the volume will not come back. Before upgrading to this version, upgrade to **at least v0.6.0** and boot it once so every LSVD volume is migrated first.
**Features**
- **Miren Anywhere** - Deploy from your laptop, a homelab box, or anything behind NAT or a firewall, and your apps are reachable anyway. Your cluster dials out to Miren Cloud and a POP at the edge carries inbound traffic back to it, so nothing ever has to accept a connection from the internet, and the "deploy succeeds but the URL just times out" wall disappears. Read the [launch post](https://miren.dev/blog/miren-anywhere). ([#913](https://github.com/mirendev/runtime/pull/913), [#924](https://github.com/mirendev/runtime/pull/924), [#778](https://github.com/mirendev/runtime/pull/778))
**Improvements**
- **Env vars injected into sandboxes gained a `MIREN_RUNTIME_` prefix** - `MIREN_APP` meant two different things: the app name injected into your running sandbox, and the CLI's target app read from your own environment. Running `miren` inside a sandbox therefore picked up the sandbox's app and silently ignored the `.miren/app.toml` in front of it. The injected variables now have unambiguous namespaced names: `MIREN_RUNTIME_APP`, `MIREN_RUNTIME_VERSION`, `MIREN_RUNTIME_INSTANCE_NUM`. The old names (`MIREN_APP`, `MIREN_VERSION`, `MIREN_INSTANCE_NUM`) are still injected as deprecated aliases with the same values, so apps reading them keep working — but they will be removed in a future release, so migrate to the `MIREN_RUNTIME_*` names when convenient. `MIREN_APP` as a CLI input is unchanged (CI pipelines that set it keep working), and running `miren` inside a sandbox now resolves the app from `.miren/app.toml` rather than the injected alias. The `MIREN_IDENTITY_*` workload identity variables are unchanged. See [App Configuration](./app-configuration.md#variables-miren-injects). ([#925](https://github.com/mirendev/runtime/pull/925), [#940](https://github.com/mirendev/runtime/pull/940))
- **Version retention reclaims disk under pressure** - Every retained app version pins a container image, an overlayfs snapshot, and a registry blob, so on a frequently-deploying cluster the age-based retention window could hold enough recent versions to fill the disk faster than they age out. When storage crosses the image-GC threshold (80%), version retention now sheds that age-based surplus and prunes each app down to its active version plus the retention count, reclaiming space automatically. Below the threshold nothing changes, and the active version and retention count are always kept. ([#936](https://github.com/mirendev/runtime/pull/936))
- **`miren deploy --summary-json`** - Write a deploy's results (the deploy id, the version Miren assigned, and the route URLs) to a JSON file, so CI and other automation can read them directly instead of scraping deploy output. The [`mirendev/actions` deploy action](https://github.com/mirendev/actions/pull/3) uses it to surface those details in CI deploy output. ([#906](https://github.com/mirendev/runtime/pull/906))
- **Audit logging for control-plane access** - Miren now records an audit trail of RPC calls against the control plane (who called what, from where, and the outcome) plus every successful certificate authentication, tagged `module=audit` so it's easy to filter in the server log. ([#910](https://github.com/mirendev/runtime/pull/910))
- **etcd sizes itself to the node** - The bundled etcd now scales its memory footprint (roughly 10% of system RAM) and its compaction and snapshot cadence to the machine at startup, instead of using one fixed configuration everywhere, so it stays a well-behaved co-tenant on everything from a 1 GB box to a 64 GB one. Its keyspace quota keeps a safe floor and only widens on larger nodes, and values stay overridable via `ETCD_*` env. ([#900](https://github.com/mirendev/runtime/pull/900))
- **A clearer answer when a self-hosted cluster is unreachable** - A cluster with a firewalled inbound port used to register fine but silently never appear in `miren cluster add`, which looked like an auth problem. Those clusters now show up marked "no reachable address" with a pointer to the fix, and the dashboard can spell out which port and protocol were blocked. ([#896](https://github.com/mirendev/runtime/pull/896), [#916](https://github.com/mirendev/runtime/pull/916))
- **`miren logs` is color-coded by service** - App output printed at the same weight as the router's request firehose, so your own logs were hard to pick out. Each line now gets a stable per-service color and a `service.id` prefix you can follow through interleaved output, and the router gets its own lane with the HTTP status colored by class so a `500` pops out of a wall of `200`s. Color and spacing adapt to the terminal but the content doesn't, so piping to `grep` is unchanged; run `miren debug colors` to see the palette. ([#942](https://github.com/mirendev/runtime/pull/942))
**Bug Fixes**
- **`miren app restart` now actually restarts** - `miren app restart` cheerfully reported "stopped 0 sandboxes" and then, well, did nothing. A typo in an entity-system write had the stop silently rejected; we corrected it and tightened up validation so the same class of bug can't recur. ([#927](https://github.com/mirendev/runtime/pull/927), [#932](https://github.com/mirendev/runtime/pull/932))
- **Mounted disks are writable by your app's user** - A disk mounted as root-owned, so an image running as a non-root user couldn't write to it without a `chown` shim in the entrypoint. Miren now hands the disk to whatever user your container runs as, and an `owner` field (`keep`, `uid`, or `uid:gid`) covers the cases that want something different. ([#935](https://github.com/mirendev/runtime/pull/935))
- **Sensitive env-var values are masked in `miren app status`** - `miren app status` didn't properly mask sensitive env-var values, so a `DATABASE_URL` with an embedded password would print in full. It now masks them consistently, and you can reveal a value one at a time with `miren env get --unmask`. ([#911](https://github.com/mirendev/runtime/pull/911))
- **Entity-store index leaks plugged** - We chased down a couple of spots where the entity store's index management left stale entries behind. Those leaks are plugged, and a new background GC keeps any future leak from growing the store unbounded. ([#898](https://github.com/mirendev/runtime/pull/898), [#903](https://github.com/mirendev/runtime/pull/903), [#917](https://github.com/mirendev/runtime/pull/917))
- **Superseded app instances are retired after an update** - A runtime update that changed how sandboxes are built could leave a stateless app running a duplicate set of instances alongside the originals. Miren now retires the superseded instances once their replacements are healthy. ([#934](https://github.com/mirendev/runtime/pull/934))
- **Orphaned disk mounts no longer wedge a node** - A disk mount left pointing at a deleted volume could spin in a tight loop that filled etcd and jammed the runtime on its next boot. The loop is fixed, and orphaned mounts like it are cleaned up automatically. ([#914](https://github.com/mirendev/runtime/pull/914))
- **Explicit local disks are recognized wherever they're mounted** - Declaring a local disk the normal way still warned that your `app.toml` had no disk configured and quietly added a second, duplicate mount. Miren now recognizes an explicitly declared local disk at any mount path. ([#930](https://github.com/mirendev/runtime/pull/930))
- **`route protect --help` shows the password-provider example again** - A duplicate registration had shadowed the fuller help text, dropping the example for protecting a route with a password provider. It's back in both `--help` and the docs. ([#931](https://github.com/mirendev/runtime/pull/931))
- **Section help works behind a global flag** - `miren -C prod auth help` showed top-level help instead of `auth`'s subcommands. Section help now works regardless of a preceding global flag. ([#908](https://github.com/mirendev/runtime/pull/908))
---
## v0.11.1
*2026-07-08*
**Improvements**
- **Podman support for containerized server installs** - `miren server docker install` is now `miren server container install`, working across both Docker and Podman (the old `server docker *` commands stay as deprecated aliases). Pick a runtime with `--runtime docker|podman` or let it auto-detect. Rootful Podman works end to end; rootless isn't supported for sandboxes, so the installer now stops up front with the fix rather than letting deploys crash-loop later. ([#894](https://github.com/mirendev/runtime/pull/894))
- **CLI colors adapt to your terminal background** - On light-background terminals the output was washed out and hard to read. Miren now detects light vs. dark and picks a readable palette, with `NO_COLOR`/`FORCE_COLOR` and a `MIREN_THEME` override honored. Run `miren debug colors` to see what it detected. ([#893](https://github.com/mirendev/runtime/pull/893))
- **Memory metrics for the control process** - The coordinator now reports its own memory and goroutine counts to the bundled metrics stack (under `entity="miren/control"`), so a growing control-plane heap is finally visible instead of invisible. A localhost pprof endpoint lets you grab a live heap profile during an incident. ([#892](https://github.com/mirendev/runtime/pull/892))
- **Install warnings stop nagging correctly-sized hosts** - A host provisioned at a nominal 8GB/100GB reports slightly less and used to trip the memory and disk warnings anyway. A 10% tolerance fixes it. ([#894](https://github.com/mirendev/runtime/pull/894))
**Bug Fixes**
- **Fixed an authentication bypass in the RPC control plane** ([GHSA-8fh7-7q4q-cq52](https://github.com/mirendev/runtime/security/advisories/GHSA-8fh7-7q4q-cq52)) - The RPC listener on `:8443` asked for a TLS client certificate but never checked it chained to the cluster CA, and a certificate identity gets full privileges. Anyone able to reach the port could present a forged cert and act as a cluster superuser. Certificates are now verified against the CA before they're trusted. Upgrade and restart the server to pick up the fix.
---
## v0.11.0
*2026-07-06*
**Features**
- **Version retention and image blob reclamation** - Every deploy used to mint an AppVersion that lived forever, bloating the entity store and pinning old images on disk. Miren now runs a periodic retention GC that per-app keeps the most recent `retention_count` versions (or anything newer than `retention_period`), always keeps the active version, and skips anything a running or pending sandbox still references. Reclaimed versions release their artifacts so the existing image and blob GC can finally recover the disk. ([#877](https://github.com/mirendev/runtime/pull/877))
- **Absolute time windows for `miren logs`** - `miren logs` could only ever look backward from now via `--last`. All four log subcommands (`app`, `sandbox`, `build`, `system`) now take a `--since`/`--until` pair, each of which accepts an RFC3339 timestamp, a friendly local form like `"2026-06-25 14:00"`, or a duration read as "that long ago." `--since 2h --until 30m` gives you exactly the window from two hours ago to thirty minutes ago. The old `--last` still works as shorthand. ([#868](https://github.com/mirendev/runtime/pull/868))
**Improvements**
- **`miren deploy` waits for healthy and reports the truth** - Deploy used to print "All traffic moved to new version" the instant it flipped the record active, before anything actually served a request, so a crash-looping app and a healthy one looked identical. Deploy now waits for the new version to come up healthy (sandbox RUNNING plus the network health check) before reporting, prints the crash logs when a version never comes up, and exits non-zero so CI catches it. The same wait wires into `rollback` and `env set`/`env delete`. ([#869](https://github.com/mirendev/runtime/pull/869))
- **Miren detects the port your app actually binds** - If an app ignores `$PORT` and hardcodes a different port, you used to get `port 3000 not reachable after 15s` and a killed instance with no hint why. Now when the configured port doesn't appear, Miren looks at what the app is actually listening on inside its sandbox: if it bound a single reachable port, traffic routes there with a note in the logs suggesting you set `$PORT` or `[services.web] port` to match; if it's ambiguous, the failure names exactly what was seen. ([#862](https://github.com/mirendev/runtime/pull/862))
- **Ruby version detection from `.ruby-version` and the Gemfile** - The Ruby buildpack never read any version source, defaulting to a hardcoded value and only honoring `[build] version`. It now reads `.ruby-version` first, falling back to an inline `ruby "x.y"` directive in the Gemfile, matching how the Go stack reads `go.mod`. The file is also staged before `bundle install` so a `ruby file: ".ruby-version"` Gemfile resolves cleanly. ([#858](https://github.com/mirendev/runtime/pull/858))
- **Go buildpack moved to a glibc/bookworm base** - Go was the last stack still building on Alpine/musl, which was where a whole class of prebuilt-binary and cgo compatibility problems lived. The Go buildpack now builds on `golang:-bookworm` (which ships a C toolchain so cgo works) and compiles multi-stage: by default the binary is built `CGO_ENABLED=0` and ships on a tiny `distroless/static` runtime, while cgo apps get an adaptive glibc runtime stage. Full patch versions from version files now resolve directly through the pull-through cache instead of being truncated to major.minor. ([#865](https://github.com/mirendev/runtime/pull/865), [#864](https://github.com/mirendev/runtime/pull/864))
- **`miren server` docker install handles taken ports gracefully** - Installing via Docker assumed it could grab 80, 443, and 8443, and failed with a raw daemon error (after cloud registration) if something already held one. A pre-flight check now verifies each port is free and tailors its guidance to which one conflicted: a taken HTTP port can move with `--http-port`, while a taken 443 (common with `tailscale serve`, nginx, Caddy) gets pointed at `--ingress-mode behind-proxy-http` so Miren serves plain HTTP behind the existing TLS terminator. ([#867](https://github.com/mirendev/runtime/pull/867))
- **Quieter server journal** - In steady state, most of the server's journal was reconcile loops heartbeating on every tick even when nothing had changed, which made it harder to spot the lines that actually mattered. The highest-volume offenders (the sandboxpool counts line, the deployment launcher's per-pass narration, the sandbox controller's per-reconcile lines) are now gated on real work instead of firing every pass. ([#878](https://github.com/mirendev/runtime/pull/878))
**Bug Fixes**
- **Fixed a coordinator that could hang for hours on startup** - After a restart, the coordinator could park in `starting entity migration` and never bind its ports, leaving apps unreachable even though their containers kept running fine. The startup maintenance phase reads the entire entity keyspace in one unbounded request, and on an etcd that has built up a lot of MVCC history (compaction falling behind, say), that read can stall indefinitely. The maintenance phase is now bounded by a 2-minute timeout, so it fails fast and retries on the next boot instead of hanging forever, and all five full-keyspace startup scans now read in bounded pages. ([#871](https://github.com/mirendev/runtime/pull/871), [#872](https://github.com/mirendev/runtime/pull/872), [#873](https://github.com/mirendev/runtime/pull/873))
- **Fixed two sandboxes getting handed the same bridge IP** - Two live sandboxes could end up sharing one IP, causing ARP conflicts and dropped packets that surfaced to your app as flapping connections and intermittent "empty reply (no responder)" errors. A container-watchdog bug was releasing a still-running sandbox's IP lease while its container kept using the address, and later that "free" IP got handed to a second sandbox. The watchdog now releases an IP only after the container is confirmed removed, and re-fetches the sandbox directly by ID (a linearizable lookup, not a laggy index read) before treating it as orphaned. ([#856](https://github.com/mirendev/runtime/pull/856))
- **Disk leases outlived dead sandboxes** - An app with a single-writer persistent disk could get stuck for ~an hour: a sandbox would bind the disk lease, fail its port health check, get marked DEAD with the lease still held, and each replacement would fail with `disk … has an active lease` until the lease aged out ~66 minutes later, repeating the cycle. The boot-failure path now releases leases like the graceful stop path already did, and the orphan-lease sweep runs on the 5-minute periodic instead of only at startup. ([#861](https://github.com/mirendev/runtime/pull/861))
- **Remote calls over Tailscale hung on the first RPC** - `miren` calls to any cluster reached over Tailscale hung with `timeout: no recent network activity`, while on-box and public-IP calls worked fine. quic-go's default initial packet size (1280 bytes, a 1308-byte IP packet with don't-fragment set) is too big for Tailscale's 1280-MTU interface, so the handshake's opening packet was rejected by the kernel and never hit the wire. Miren now pins the QUIC `InitialPacketSize` to 1200 so the handshake fits. ([#870](https://github.com/mirendev/runtime/pull/870))
- **Fixed unbounded memory growth on a giant log line** - An app that wrote a large blob to stdout without newlines could grow the control process's memory without limit, and on a busy host that could be enough to trip the OOM killer and disrupt other apps sharing the machine. A log line only flushes on `\n`, so a multi-gigabyte payload with no newline accumulated in a single unbounded buffer before being parsed and marshaled as one entry. The partial-line buffer is now capped at 1 MiB, so memory stays bounded no matter what an app emits. ([#875](https://github.com/mirendev/runtime/pull/875))
- **Fixed a BuildKit cache that grew past its configured cap** - The build cache could climb well past its configured size limit and eat into your disk, even though a manual prune always brought it right back down, which was the tell that it was collectable and GC just wasn't doing it. The generated `buildkitd.toml` set `keepBytes` (a floor that BuildKit migrates to `reservedSpace`) but left the real ceiling, `maxUsedSpace`, at zero, and a 7-day `keepDuration` transitively pinned nearly the whole cache through recently-touched descendants. The config now sets `maxUsedSpace` and uses age-less policy tiers so GC actually enforces the cap. ([#879](https://github.com/mirendev/runtime/pull/879))
- **Fixed a flannel log-spam loop that could fill a node's disk** - On a long-running node, flannel's networking could spin into a tight retry loop that wrote several gigabytes of logs a day until the disk filled and the node dropped out of the cluster. The cause was in flannel's legacy etcd subnet-watch: it never advanced its watch start revision, so once etcd compacted past that revision every reconnect failed with "required revision has been compacted" and immediately retried. Miren now pins a `mirendev/flannel` fork that re-lists at a current revision when the watch is compacted and advances the resume revision as events arrive, so a reconnect moves forward instead of hot-looping. (The same patch is being upstreamed.) ([#874](https://github.com/mirendev/runtime/pull/874))
- **Non-Go files dropped from pure-Go images** - The Go stack's default pure-Go path assembled a `distroless-static` runtime that copied only `/bin/app`, silently dropping READMEs, templates, and data dirs, which broke apps that read files at runtime relative to `/app`. Every other stack already shipped the full tree; the pure-Go branch now copies the built `/app` tree too, excluding only Go source and module build inputs the compiled binary never needs. ([#882](https://github.com/mirendev/runtime/pull/882))
- **Auto-mounted local storage orphaned duplicate pools** - An unattended reboot took an app with auto-mounted local storage offline for ~90 minutes while a healthy sandbox sat idle in a sibling pool. Auto-mounted storage was patched into the sandbox spec at build time rather than the resolved config, which spawned a replacement pool but left the stale-pool drain's gate off, so the superseded pool lingered forever; and the activator pinned each `(version, service)` to the first pool it saw and never re-pointed it. The auto-mount is now registered as a real disk so the drain reaps it, and the activator drops a binding once its pool stops referencing the version. ([#883](https://github.com/mirendev/runtime/pull/883))
- **buildkitd reuse failed after an ungraceful miren restart** - With `KillMode=process`, a crash, double-SIGTERM, or stop that exceeded the timeout left buildkitd orphaned but still running; the next boot reused that daemon without restarting it, but its client-side session state was bound to the exited process, so new builds failed with `NotFound: no such session`. Miren now rebinds the daemon to the current process on restart. ([#885](https://github.com/mirendev/runtime/pull/885))
- **Fixed an activator crash under heavy activation load** - When a lot of apps woke from idle at once, a mutex-handling bug in the activator's pool-lookup retry loop could crash the server with `fatal error: sync: Unlock of unlocked RWMutex`, which on a single-node cluster takes the whole process down with it. A branch that noticed another goroutine had already populated the pool cache did a bare `continue`, which re-entered the inner retry loop with the lock released instead of breaking back out to the outer wait logic; the next backoff then tried to unlock an already-unlocked mutex. The loop is now labeled so that branch breaks all the way out with the lock in the expected state, and a new stress test reconstructs the race. It surfaced now because [#883](https://github.com/mirendev/runtime/pull/883)'s pool-eviction work increased pool churn enough to push traffic through this path. ([#888](https://github.com/mirendev/runtime/pull/888))
- **Fixed an indexwatch panic on watch-stream reconnect** - A watch-stream reconnect could crash the process with `panic: send on closed channel` from the indexwatch layer, because the `updates` channel had two independent senders and a `select` on `ctx.Done()` doesn't protect against a send racing the close (a closed-channel send case is always ready, and `select` picks randomly). The send and close are now serialized behind a mutex plus a `closed` flag. ([#887](https://github.com/mirendev/runtime/pull/887))
**Documentation**
- Added a [Workload Identity](https://miren.md/workload-identity) guide covering the per-cluster OIDC issuer, the token file and on-demand token server, AWS/GCP/Azure federation, and external token verification. ([#855](https://github.com/mirendev/runtime/pull/855))
- Documented [password route protection](https://miren.md/route-protect) (adding a password provider, attaching it, the login/session flow, and rotation), which had existed in the CLI but only in the auto-generated command reference. ([#866](https://github.com/mirendev/runtime/pull/866))
- Removed `post_import` from the docs. It was a documented, parsed, and validated `app.toml` key that nothing in the runtime ever read, so a deploy with `post_import = "…rake db:migrate"` reported success while the migration silently never ran. Pulling it from the docs stops advertising a step that doesn't happen. ([#884](https://github.com/mirendev/runtime/pull/884))
---
## v0.10.0
*2026-06-09*
**Features**
- **Workload identity tokens for sandboxes** - Every sandbox now receives a signed OIDC workload identity token (GitHub Actions-style) at `/var/run/miren/identity-token`, with `MIREN_IDENTITY_TOKEN_PATH`, `MIREN_OIDC_ISSUER_URL`, and `MIREN_IDENTITY_TOKEN_URL` injected into the environment. Your cluster publishes standard `/.well-known/openid-configuration` and JWKS endpoints, so external systems like AWS STS can verify tokens and federate access — no long-lived cloud credentials baked into your app. Tokens default to RS256 (universally supported by federation verifiers), auto-refresh on a background loop, and an on-demand endpoint lets a sandbox request tokens with a custom audience or TTL. Works on both embedded and distributed runners. ([#834](https://github.com/mirendev/runtime/pull/834), [#846](https://github.com/mirendev/runtime/pull/846), [#852](https://github.com/mirendev/runtime/pull/852))
- **Admin API is now GA** - The admin API graduates out of Miren Labs and is always on — no more `--labs adminapi` flag needed. Expose and call admin methods on your app over JSON-RPC; see the [admin interface docs](https://miren.md/admin-interface) for the security model, auditing, and per-language examples. ([#832](https://github.com/mirendev/runtime/pull/832))
- **Automatic TLS for cloud-provisioned cluster hostnames** - When a cluster has a cloud-provisioned `*.miren.systems` hostname, Miren now provisions a real ACME certificate for it on startup instead of serving the self-signed fallback. The hostname is pinned in the allowed-hosts set so route deletions can't strip its cert. ([#836](https://github.com/mirendev/runtime/pull/836))
**Improvements**
- **Cleaner `miren logs` output** - Structured JSON log lines from your app are now parsed at ingress: the message becomes the log body, `time`/`level` noise is stripped, and your own fields are promoted to first-class attributes. Internal bookkeeping is namespaced under `miren.*` and hidden from text output, and log brackets show the real short ID (e.g. `[CBZ]`) instead of a truncated entity key. Existing `--service` / `sandbox` filters keep working against both old and new entries. ([#838](https://github.com/mirendev/runtime/pull/838))
- **`logs -f` collapses repeated lines** - When following logs, consecutive lines that differ only by their timestamp (e.g. a once-a-second health ping) now collapse into a single live-updated `[ Repeated 14x over 14s ]` summary instead of burying new output. Only engages for interactive text follow — JSON, piped, and non-follow output stay verbatim, so `grep` and machine consumers are unaffected. ([#845](https://github.com/mirendev/runtime/pull/845))
**Bug Fixes**
- **Fixed TLS failures reaching recreated distributed runners** - `miren app run` and `miren sandbox exec` against a distributed runner could fail with `certificate is valid for ... not ` after the runner VM was recreated with a new internal IP but a persisted certificate. Runners now detect a stale certificate on start and re-issue it from the coordinator, self-healing on the next restart. ([#848](https://github.com/mirendev/runtime/pull/848))
**Documentation**
- Expanded the [admin interface](https://miren.md/admin-interface) page with the full security model, auditing behavior, JSON-RPC shape, and CLI usage. ([#831](https://github.com/mirendev/runtime/pull/831))
- Expanded the [terminology](https://miren.md/terminology) page from 12 to 35 canonical definitions. ([#850](https://github.com/mirendev/runtime/pull/850))
---
## v0.9.1
*2026-06-04*
**Bug Fixes**
- **New services could come up without a network address** - If the cluster hit a brief internal hiccup (etcd compaction, a leader change, or a network blip), a service created right around that moment could be left without an IP and stay unreachable until the server was restarted. If you've seen a freshly deployed service that never became reachable for no obvious reason, this was a likely cause. ([#841](https://github.com/mirendev/runtime/pull/841))
- **Ephemeral preview deploys would stop responding until a server restart** - A preview (ephemeral) deploy would work at first, then stop responding to requests, and never serve again until the miren server itself was restarted. That situation is now fixed; these deploys keep serving and recover on their own. ([#837](https://github.com/mirendev/runtime/pull/837))
- **NodePort services accumulated duplicate firewall rules** - NodePort services piled up duplicate iptables rules over time, and HTTP services that declared a `node_port` didn't always get one. Both are fixed, and existing duplicate rules are cleaned up automatically as sandboxes recycle. ([#840](https://github.com/mirendev/runtime/pull/840))
---
## v0.9.0
*2026-05-28*
**Breaking Changes**
- **`auth provider add` reshaped into per-type subcommands** - The asymmetric pair of `auth provider add NAME --provider-url ...` (OIDC) and the separate `auth provider add-password NAME ...` is gone, replaced by a single shape: `auth provider add oidc|github|password NAME [flags]`. Migration: prepend `oidc` to existing OIDC commands, and replace `add-password` with `add password` (with a space). The CLI now exposes three types directly instead of an "oidc with optional connector" indirection. ([#817](https://github.com/mirendev/runtime/pull/817))
**Features**
- **Route protection: native GitHub identity provider** - GitHub was the awkward gap in the v0.8.0 route protection story. It has no OIDC endpoint, so the answer was "stand up Dex yourself." Miren now talks to GitHub directly via an embedded Dex connector library, and adding a provider is just `miren auth provider add github my-gh --client-id $ID --client-secret $SECRET --org mirendev:platform,eng`. Org and team membership land in your app as `X-User-Login` and `X-User-Groups` headers. ([#817](https://github.com/mirendev/runtime/pull/817))
**Improvements**
- **`miren doctor server` checks QUIC reachability** - The endpoint probe now exercises QUIC alongside HTTPS/HTTP. A host firewall that allows TCP 8443 but blocks UDP 8443 (a common UFW misconfiguration) used to silently break every external `miren deploy` with all-green doctor output; the new probe surfaces it with a pointed "host firewall may be blocking inbound UDP" message. ([#819](https://github.com/mirendev/runtime/pull/819))
- **`miren server register` restarts the systemd unit for you** - Re-registering against a live cluster used to print a "you must now restart miren server" warning that was easy to miss. The CLI now detects an active `miren.service` and restarts it itself, while fresh `miren install` flows stay quiet because install owns the lifecycle. ([#824](https://github.com/mirendev/runtime/pull/824))
- **Better admin CLI per-method help** - `miren admin --help` (or `-h`) now shows method help instead of erroring with `unknown parameter(s): help`. Calling a method that declares params with no args renders the help block instead of a raw JSON-RPC error. Missing-required and unknown-param errors embed the full method definition so you can see what was expected at the point of failure. ([#823](https://github.com/mirendev/runtime/pull/823))
**Bug Fixes**
- **Fixed `sandbox exec` stdout redirection and short-ID lookup** - `miren sandbox exec -i app cat /data/db > backup.db` used to print the database contents to the terminal because the TTY check only looked at stdin; redirected stdout now stays binary-clean and skips PTY allocation. The same command also now resolves short IDs like `7g7` (as displayed by `sandbox list`) instead of erroring with "no container found". ([#828](https://github.com/mirendev/runtime/pull/828))
- **Fixed deploy panic leaving the server lock stuck for 30 minutes** - An explain-mode deploy could race RPC stream-handler goroutines against the main goroutine closing the status channel, panicking the deploy and leaving the server-side deploy lock held until its TTL. The status channel is now serialized behind a mutex, and a panic-recovery guard releases the lock immediately on crash. ([#822](https://github.com/mirendev/runtime/pull/822))
- **Fixed ephemeral preview deploys scaling under load** - Ephemeral pools followed normal auto-mode scaling, so traffic bursts ratcheted the pool up instead of queuing on the single preview sandbox. Ephemeral pools are now pinned at `desired_instances = 1` and refuse to scale. The same PR fixes an activator race that returned leases with empty URLs (causing httpingress to fail with `unsupported protocol scheme ""`) and bumps the per-request wait cap from 50s to 120s to cover cold image pulls. ([#821](https://github.com/mirendev/runtime/pull/821))
- **Fixed `tls.additional_names` / `tls.additional_ips` rejected in `behind-proxy-http` mode** - The validator refused these fields under `behind-proxy-http`, but they also feed the API server and etcd certs, which exist regardless of ingress mode. Operators had no way to set just the API cert SANs, which broke external `miren deploy` with `leaf cert SAN doesn't match`. ([#820](https://github.com/mirendev/runtime/pull/820))
---
## v0.8.0
*2026-05-20*
**Breaking Changes**
- **Ingress configuration reshaped around named modes** - The ingress and TLS configuration is now organized as three explicit modes — `tls-autoprovision` (default, behavior unchanged), `behind-proxy-http`, and `behind-proxy-https` — with an optional `ingress.address` for custom bind addresses. The legacy `tls.standard_tls` knob is retired (dev environments already used `--self-signed-tls`). One small behavior change in autoprovision mode: requests to raw-IP or localhost Hosts on `:80` now follow the HTTPS redirect like any other request instead of being shortcut to the default route over plain HTTP. Pick `behind-proxy-http` if you want explicit plain-HTTP for a dev workflow. ([#799](https://github.com/mirendev/runtime/pull/799))
**Features**
- **Route protection: shared-password auth** - Protect any route behind a shared password. Run `miren auth provider add-password my-gate`, then `miren route protect blog.example.com --provider my-gate`, and your route gets a login form with a 24h encrypted session cookie on success. Good for staging gates, internal dashboards, and anywhere "the half-dozen people who know the password" is enough. ([#787](https://github.com/mirendev/runtime/pull/787))
- **Route protection: OIDC single sign-on** - Plug your identity provider into a Miren route and the authenticated user's identity arrives at your app as plain HTTP headers like `X-User-Email`. No OAuth library, JWT validation, or callback handler needed in your code. Google, GitLab, and self-hosted Keycloak work directly; GitHub via a Dex federation layer. ([#764](https://github.com/mirendev/runtime/pull/764), [#788](https://github.com/mirendev/runtime/pull/788))
- **Route protection: Web Application Firewall** - Inspect requests for attack payloads before they reach your app. `miren route waf --level N` runs Coraza with the full OWASP Core Rule Set in front of any route, blocking SQL injection, XSS, path traversal, command injection, and friends. Levels 1-4 map to OWASP paranoia levels; level 1 is the right starting point for most apps. When both auth and WAF are on a route, WAF runs first. See the [route protection docs](https://miren.md/route-protect) and the [announcement post](https://miren.dev/blog/route-protection). ([#786](https://github.com/mirendev/runtime/pull/786))
- **Ephemeral deployments** - Deploy a labeled, time-boxed version of your app that lives alongside the active version and is reachable at `.` without touching production traffic. `miren deploy --ephemeral pr-33 --ttl 24h` creates one; `miren app versions` shows what's live, what's ephemeral, and when each expires. Expired versions are cleaned up automatically, and TLS certs provision on first hit so ephemeral subdomains get real certificates instead of the cluster's self-signed fallback. ([#745](https://github.com/mirendev/runtime/pull/745), [#807](https://github.com/mirendev/runtime/pull/807))
- **Smarter `miren init`** - `miren init` now scans your project for the environment variables your app actually needs and pre-sets them on the app before the first deploy, the same as if you'd run `miren config set` yourself. Generated secrets (Rails `SECRET_KEY_BASE`), file-backed keys (`RAILS_MASTER_KEY`), framework defaults, and source-detected reads across Python/Node/Bun/Go/Ruby/Rust are recognized; the ones we can resolve are picked up automatically by the first build. See [What `miren init` Does for You](./app-configuration.md#what-miren-init-does-for-you). ([#567](https://github.com/mirendev/runtime/pull/567))
**Improvements**
- **Automatic npm/bun installation for mixed-stack apps** - Rails, Django, Go, or Rust apps that ship a `package.json` (or `bun.lock`/`bun.lockb`) now get npm or bun installed onto the base image automatically and `npm install`/`bun install` run as the unprivileged `app` user. Apps that vendor their own `node_modules` still skip the install step. ([#810](https://github.com/mirendev/runtime/pull/810))
- **Per-service `port_timeout` in app.toml** - Services that need longer than the default 15s to bind their port (Prisma migrations on first boot are a classic culprit) can now declare `port_timeout = "120s"` in their service block. Web and worker services in the same app can hold different timeouts so a slow-booting worker doesn't make web fail-fast change too. ([#792](https://github.com/mirendev/runtime/pull/792))
- **Sandbox hostname in `/etc/hosts`** - Each sandbox now gets a real IP→hostname entry in `/etc/hosts` so processes that resolve their own hostname (e.g. Erlang's EPMD) see their sandbox IP instead of loopback. ([#784](https://github.com/mirendev/runtime/pull/784))
- **CLI top-level help grouped by user intent** - `miren --help` now organizes its 30+ top-level commands into five named buckets (Getting started, Monitoring your app, Configuring your app, Client operations, Server operations) so deployers don't have to scan past server-administration commands to find `deploy` and `rollback`. ([#812](https://github.com/mirendev/runtime/pull/812))
- **Honest upload progress and ETA in `miren deploy`** - The "total" in the upload progress line is no longer fictional and the line now shows a time-domain ETA (`eta ~8h 15m`) instead of a fluctuating projected total. ([#804](https://github.com/mirendev/runtime/pull/804))
- **`sandbox exec` accepts a positional sandbox ID** - `miren sandbox exec yKm -- hostname` now works the way you'd expect from copying the ID out of `sandbox list`. ([#800](https://github.com/mirendev/runtime/pull/800))
- **Trustworthy public-address advertising** - The coordinator now trusts per-family netcheck results so an IPv4 success doesn't drop IPv6 reachability, and CGNAT/Tailscale addresses in `100.64.0.0/10` are filtered out of discovered IPs by default. Operators who actually want a CGNAT address advertised can set it explicitly via `AdditionalIPs`. ([#808](https://github.com/mirendev/runtime/pull/808), [#809](https://github.com/mirendev/runtime/pull/809))
- **Better `.gitignore` handling in source uploads** - Switched the source-bundler to go-git's gitignore matcher, which correctly handles negations like Bridgetown's default `!/tmp/pids/` pattern. Vanilla `bridgetown new` apps now deploy without a missing-pidfile crash. ([#777](https://github.com/mirendev/runtime/pull/777))
**Bug Fixes**
- **Fixed containerd FIFO directory leak on sandbox teardown** - Sandbox cleanup paths now attach a non-nil `cio.NewAttach()` when fetching tasks for deletion so containerd actually removes its FIFO directories. The leak was exhausting `/run`'s inode budget on long-lived hosts. ([#797](https://github.com/mirendev/runtime/pull/797))
- **Fixed `miren upgrade --check --channel` ignoring `--channel`** - The version/channel resolution happened after the `--check` early return, so `miren upgrade --check --channel main` was silently reporting against `latest`. Resolution is now shared across `miren upgrade`, `server upgrade`, and `runner upgrade` so they can't drift apart again. ([#798](https://github.com/mirendev/runtime/pull/798))
- **Fixed `miren login` cancel being reported as a timeout** - Hitting Ctrl-C during login now distinguishes cancellation from `context.DeadlineExceeded` and reports it accordingly. ([#773](https://github.com/mirendev/runtime/pull/773))
---
## v0.7.1
*2026-04-22*
**Improvements**
- **Cut steady-state log noise** - Roughly 76% reduction in server log volume during normal operation. Removed per-request auth success logs, redundant controller reconcile lines, heartbeat spam, and other content-free chatter that drowned out useful signal. ([#767](https://github.com/mirendev/runtime/pull/767))
**Bug Fixes**
- **Fixed Valkey addon causing app connection errors** - The valkey sandbox was starting without `--requirepass` while apps received a URL containing the password, so every client got `ERR AUTH called without any password configured`. Password is now baked directly into the server command at provision time. ([#771](https://github.com/mirendev/runtime/pull/771))
- **Fixed short IDs not resolving for several commands** - `sandbox exec`, `logs sandbox`, and `deploy cancel` weren't resolving short IDs like you'd expect from seeing them in other output. Worst case was `deploy cancel` silently no-op'ing while reporting success, leaving the deploy lock stuck until its 30-minute timeout. ([#769](https://github.com/mirendev/runtime/pull/769))
- **Fixed Rust source-only deploys shipping stale binaries** - Changing only source files in a Rust app could silently ship the previously built binary. The workspace crate now force-rebuilds on every deploy while the dependency cache stays intact. ([#768](https://github.com/mirendev/runtime/pull/768))
- **Fixed activator fail-fast killing healthy replacement deploys** - When a crash-looping version was replaced with a fix, the activator counted dead sandboxes from the broken version and fail-fasted the fix before it got to boot a sandbox of its own. Fail-fast accounting is now scoped to the requesting version. ([#766](https://github.com/mirendev/runtime/pull/766))
- **Fixed silent app.toml parse errors during build** - A parse error in `app.toml` used to be logged and discarded, and the build continued as if no config existed — services, env vars, and addons silently gone. Parse errors now fail the build and surface the full diagnostic (file, line, suggestions) to the client. ([#765](https://github.com/mirendev/runtime/pull/765))
---
## v0.7.0
*2026-04-14*
**Breaking Changes**
- **Local shared storage is now opt-in** - Apps that need persistent local storage now declare it explicitly as a disk with `provider = "local"` in app.toml instead of getting an automatic bind mount at `/miren/data/local`. This makes storage dependencies visible to the scheduler, which needs to know about them as we build out multi-node cluster support. If your app has existing data in local storage, Miren detects it automatically, keeps mounting it, and shows a warning during deploy with a link to add the config. No data loss, just a nudge to make the dependency explicit at your own pace. See [Migrating from automatic local storage](https://miren.md/disks#migrating-from-automatic-local-storage) for details. ([#700](https://github.com/mirendev/runtime/pull/700), [#719](https://github.com/mirendev/runtime/pull/719))
**Features**
- **Managed addons** - Miren now provisions and manages backing services alongside your apps. Add a database or cache with `miren addon create`, and Miren handles the container lifecycle, networking, and credential injection. Launch includes PostgreSQL, MySQL, Valkey, Memcache, and RabbitMQ, with version selection and custom OCI image support. ([#688](https://github.com/mirendev/runtime/pull/688), [#706](https://github.com/mirendev/runtime/pull/706), [#720](https://github.com/mirendev/runtime/pull/720), [#726](https://github.com/mirendev/runtime/pull/726), [#727](https://github.com/mirendev/runtime/pull/727), [#743](https://github.com/mirendev/runtime/pull/743), [#755](https://github.com/mirendev/runtime/pull/755), [#758](https://github.com/mirendev/runtime/pull/758), [#760](https://github.com/mirendev/runtime/pull/760))
- **Short entity IDs** - Entities now get short 3-8 character identifiers that work anywhere a full ID does. CLI tables are easier to read and IDs are easy to type from memory. ([#696](https://github.com/mirendev/runtime/pull/696), [#721](https://github.com/mirendev/runtime/pull/721), [#741](https://github.com/mirendev/runtime/pull/741))
- **CLI aliases** - Define custom command shortcuts in `.miren/app.toml` under `[aliases]`. Supports multi-word names and appends extra arguments. ([#693](https://github.com/mirendev/runtime/pull/693))
- **Disk undelete** - Deleted disks are now soft-deleted with a 7-day retention window. `disk undelete` restores them, and `disk list-deleted` shows what's recoverable. ([#694](https://github.com/mirendev/runtime/pull/694))
- **`miren app restart`** - New command to restart an app immediately. Deploys also now reset crash cooldown, so a new deploy isn't blocked by stale backoff from a previous crash. ([#702](https://github.com/mirendev/runtime/pull/702))
**Improvements**
- **Better config error messages** - Typos and validation errors in `.miren/app.toml` now show file location, underline the problem, and suggest corrections with color output. ([#709](https://github.com/mirendev/runtime/pull/709))
- **Faster `app list`** - App list aggregation moved server-side, significantly reducing round trips for clusters with many apps. ([#701](https://github.com/mirendev/runtime/pull/701))
- **Disk-aware pool draining** - Services with disks now drain old pools before starting new ones, preventing data conflicts during deploys. ([#725](https://github.com/mirendev/runtime/pull/725))
- **Hardened embedded etcd** - Defenses against bbolt freelist bloat that could cause etcd to slow down over time. ([#733](https://github.com/mirendev/runtime/pull/733))
- **App name in sandbox list** - `sandbox list` now shows which app each sandbox belongs to. ([#757](https://github.com/mirendev/runtime/pull/757))
- **Dual-stack IP discovery** - Cluster address reporting now discovers both IPv4 and IPv6 public addresses, and TLS certificates include public IP SANs automatically. ([#708](https://github.com/mirendev/runtime/pull/708), [#712](https://github.com/mirendev/runtime/pull/712))
**Bug Fixes**
- **Fixed deploy from subdirectory using wrong source directory** ([#716](https://github.com/mirendev/runtime/pull/716))
- **Friendlier TLS certificate behavior when DNS isn't ready yet** - ACME challenges now back off gracefully instead of burning through rate limits when a domain's DNS isn't fully propagated. ([#710](https://github.com/mirendev/runtime/pull/710), [#730](https://github.com/mirendev/runtime/pull/730))
- **Fixed overlay IP allocator assigning duplicate IPs after restart** ([#707](https://github.com/mirendev/runtime/pull/707))
- **Fixed sandbox hostname resolution for addon sub-containers** ([#728](https://github.com/mirendev/runtime/pull/728))
- **Fixed stale pool reuse when volume/mount config changes** ([#718](https://github.com/mirendev/runtime/pull/718))
- **Self-healing loop-backed volumes after unclean shutdown** - After a SIGKILL, miren now detects and cleans up stale loop devices instead of mounting a second one and corrupting the filesystem. ([#756](https://github.com/mirendev/runtime/pull/756))
- **Fixed release bundle detection for CLI-only installs** ([#759](https://github.com/mirendev/runtime/pull/759))
---
## v0.6.1
*2026-03-24*
**Improvements**
- **Faster system log queries** - `miren logs system` now returns results in under a second instead of ~10 seconds by using VictoriaLogs' native `limit` parameter instead of server-side sorting. ([#681](https://github.com/mirendev/runtime/pull/681))
- **JSON output for more CLI commands** - `debug netdb list`, `debug netdb status`, and `doctor config` now support `--format json`. Added `--json` as a shorthand for `--format json` on all commands that support it. ([#687](https://github.com/mirendev/runtime/pull/687))
**Bug Fixes**
- **Fixed delta deploys failing when cached and changed files share directories** - Deploying after a cached delta could fail with `mkdir: file exists` when both the cached and changed file sets contained the same directory. ([#689](https://github.com/mirendev/runtime/pull/689))
- **Fixed CLI commands ignoring per-app cluster selection** - Commands like `route list`, `sandbox list`, and `app list` now respect the cluster chosen via `cluster switch` in an app directory, instead of silently falling back to the global active cluster. ([#683](https://github.com/mirendev/runtime/pull/683))
**Documentation**
- Added a top-level Deployment docs page covering the full deploy lifecycle. ([#684](https://github.com/mirendev/runtime/pull/684))
---
## v0.6.0
*2026-03-17*
**Features**
- **Disk storage system overhaul** - The disk subsystem has been rewritten with a new Universal mode backed by loop devices, replacing the previous LSVD/NBD architecture. Existing LSVD volumes are migrated automatically on first boot. Includes disk backup/restore support and a new cloud accelerator mode for segment upload/replay. ([#639](https://github.com/mirendev/runtime/pull/639))
- **System logs accessible from the CLI** - `miren logs system` queries server logs directly from the CLI — same interface as app logs, with `--follow`, `--last`, and `--format json` all working. Under the hood, server logs are ingested into VictoriaLogs through a structured handler, so all attributes (module, level, error fields) are preserved and queryable. `miren logs` is also restructured into proper subcommands (`app`, `sandbox`, `build`, `system`) — bare `miren logs` still works as shorthand for `miren logs app`. ([#645](https://github.com/mirendev/runtime/pull/645), [#662](https://github.com/mirendev/runtime/pull/662), [#679](https://github.com/mirendev/runtime/pull/679))
- **Delta file transfer for deploys** - The deploy client now sends a file manifest first; the server compares it against the cached source from the previous deploy and the client only uploads what changed. Subsequent deploys of unchanged code upload nothing. ([#670](https://github.com/mirendev/runtime/pull/670))
- **Wildcard routes** - Route all subdomains of a domain to a single app with `miren route set *.example.com myapp`. Wildcard routes match any subdomain and the bare domain, with exact routes taking priority. TLS certificates are provisioned automatically for each matching subdomain. ([#659](https://github.com/mirendev/runtime/pull/659))
**Improvements**
- **Upload progress bar and caching summary** - Deploy uploads show a real progress bar with percentage and speed. After upload, a summary shows how many files were cached from the previous deploy and estimated time saved. ([#680](https://github.com/mirendev/runtime/pull/680))
- **Eager TLS certificate provisioning** - Adding a route now triggers certificate provisioning immediately in the background. HTTPS connections no longer stall 30–90 seconds on first access. ([#661](https://github.com/mirendev/runtime/pull/661), [#664](https://github.com/mirendev/runtime/pull/664))
- **Deploy progress tracks build steps** - The progress bar now tracks build steps instead of layer downloads, so it stays active on cached-image deploys. ([#665](https://github.com/mirendev/runtime/pull/665))
- **JSON output for log and app commands** - `miren logs` (all subcommands), `miren app`, `miren app status`, and `miren app history` all support `--format json` for scripting and automation. ([#675](https://github.com/mirendev/runtime/pull/675), [#673](https://github.com/mirendev/runtime/pull/673))
- **Group help for CLI discovery** - `miren help --commands` lists all commands with synopses (supports `--format json`). `miren help app.list version sandbox.stop` shows help for multiple commands at once using dot notation. ([#676](https://github.com/mirendev/runtime/pull/676))
- **System requirements check at install** - The installer now verifies at least 4 GB memory and 50 GB storage before proceeding. ([#663](https://github.com/mirendev/runtime/pull/663))
**Bug Fixes**
- **Fixed rapid deploys causing orphaned sandboxes** - Deploying the same app multiple times in quick succession no longer leaves orphaned sandboxes running or stale routing entries. ([#672](https://github.com/mirendev/runtime/pull/672))
- **Fixed disk config silent failures** - Using `size` instead of `size_gb` in disk config no longer leaves sandboxes stuck in PENDING forever; unknown fields now surface as errors. ([#666](https://github.com/mirendev/runtime/pull/666))
- **Fixed app TUI showing wrong concurrency** - The `miren app` details view now correctly shows fixed instance counts instead of "auto" for services with explicit concurrency settings. ([#667](https://github.com/mirendev/runtime/pull/667))
- **Fixed env var file values including trailing newlines** - `KEY=@filepath` now trims trailing line endings from file contents. ([#671](https://github.com/mirendev/runtime/pull/671))
---
## v0.5.0
*2026-03-10*
**Features**
- **Multi-port and non-HTTP services** - Apps can now expose multiple ports with different protocols (TCP, UDP, HTTP) using `[[services..ports]]` in app.toml. Node ports are validated at deploy time to prevent conflicts. Enables use cases like IRC servers alongside HTTP endpoints. ([#641](https://github.com/mirendev/runtime/pull/641))
- **CI deployments with GitHub Actions** - Deploy from GitHub Actions and other CI systems using short-lived identity tokens instead of long-lived secrets. Bind repos to apps with `miren auth ci add --github OWNER/REPO -a APP`, and GitHub Actions will authenticate automatically. Target clusters from CI with the `MIREN_CLUSTER` env var. ([#631](https://github.com/mirendev/runtime/pull/631), [#633](https://github.com/mirendev/runtime/pull/633), [#635](https://github.com/mirendev/runtime/pull/635))
- **Required environment variables** - Declare env vars as `required = true` in app.toml and deploys will fail early with a clear message listing what's missing. Vars can also be marked `sensitive` and given a `description` that appears in `miren env list`. ([#638](https://github.com/mirendev/runtime/pull/638))
**Improvements**
- **Zero-config app initialization** - Running any app command without an `app.toml` now offers to run `miren init` for you interactively, inferring the app name from your directory. In CI, the error message tells you what to do. ([#654](https://github.com/mirendev/runtime/pull/654))
- **Bun runtime detection without lockfile** - Bun apps without dependencies are now detected via `bunfig.toml`, the `packageManager` field in package.json, or `bun`/`bunx` commands in package.json scripts. ([#656](https://github.com/mirendev/runtime/pull/656))
- **`env set`/`env delete` show deployment progress** - Environment variable changes now go through the deployment service and show activation progress and routes, matching the UX of `deploy` and `rollback`. ([#630](https://github.com/mirendev/runtime/pull/630))
**Bug Fixes**
- **Fixed 502 errors during deployment rollovers** - The HTTP ingress now retries on a stale connection instead of immediately returning 502, and the launcher waits for new sandboxes to be ready before scaling down old ones. ([#637](https://github.com/mirendev/runtime/pull/637))
- **Fixed streaming responses being buffered** - SSE, chunked downloads, and long-polling now work correctly. The previous timeout implementation buffered entire responses in memory before sending; timeouts are now handled at the transport level so data streams incrementally. ([#634](https://github.com/mirendev/runtime/pull/634))
- **Fixed `miren exec` panic after command finishes** - `miren exec` and `miren app run` no longer panic when the remote command completes. ([#655](https://github.com/mirendev/runtime/pull/655))
- **Fixed `app run` panic when stdin isn't a terminal** - `miren app run` no longer panics in non-interactive contexts like piped input or CI. ([#632](https://github.com/mirendev/runtime/pull/632))
- **Fixed firewall rules leaking on sandbox teardown** - Port forwarding rules are now cleaned up when sandboxes stop, preventing stale rules from making node ports unreachable after redeployment. ([#644](https://github.com/mirendev/runtime/pull/644))
---
## v0.4.1
*2026-02-18*
**Bug Fixes**
- **Fixed app startup directory regression** - Apps deployed before the WORKDIR fix (v0.4.0) would boot with CWD `/` instead of `/app`, causing crashes. The `/app` default is now restored as a fallback for existing app versions without a stored `start_directory`. ([#621](https://github.com/mirendev/runtime/pull/621), [#623](https://github.com/mirendev/runtime/pull/623))
- **Fixed noisy RPC error logs** - Deploying to a server without telemetry capabilities no longer produces a spurious `[ERROR] error resolving capability` message. Optional capability lookups now degrade quietly at Debug level. ([#622](https://github.com/mirendev/runtime/pull/622))
---
## v0.4.0
*2026-02-17*
**Features**
- **First-class rollbacks** - Redeploy a previous app version without rebuilding. Use `miren rollback` for an interactive picker that shows your recent versions, or `miren deploy --version ` to deploy a specific version directly. Each rollback creates a full deployment record with provenance tracking. ([#590](https://github.com/mirendev/runtime/pull/590))
- **OpenTelemetry tracing** - Comprehensive distributed tracing across the Miren runtime. Traces cover HTTP ingress, deploy/build pipelines, containerd operations, and CLI commands. CLI spans are shipped through the server via a proxy exporter. Cluster identity is included in trace resource attributes. See [Observability](./observability.md) for configuration details. ([#595](https://github.com/mirendev/runtime/pull/595), [#601](https://github.com/mirendev/runtime/pull/601), [#602](https://github.com/mirendev/runtime/pull/602), [#609](https://github.com/mirendev/runtime/pull/609))
**Improvements**
- **Per-app cluster pinning** - The CLI now remembers which cluster to use for each app. The first time you use `-C ` with a command, that cluster is saved locally for the app. Resolution order: explicit `-C` flag > per-app pin > global active cluster. Use `miren cluster current` to see which cluster the CLI will target. ([#596](https://github.com/mirendev/runtime/pull/596))
- **Better app.toml error messages** - Invalid `app.toml` files now surface the actual parse error instead of the confusing "app is required" message. ([#614](https://github.com/mirendev/runtime/pull/614))
- **Exclude dead sandboxes from listing** - `miren sandbox list` now hides dead sandboxes by default, showing only active ones. Use `--all` to see everything. ([#585](https://github.com/mirendev/runtime/pull/585))
- **Redact secrets from debug bundles** - `miren debug bundle` now redacts sensitive information and includes guidance on sharing bundles safely. ([#612](https://github.com/mirendev/runtime/pull/612))
- **`-v` flag moved to `-V`** - The version flag is now `-V`/`--version`, freeing `-v` for `--verbose` across all commands. ([#593](https://github.com/mirendev/runtime/pull/593), [#594](https://github.com/mirendev/runtime/pull/594))
- **Disk mount robustness** - Improved disk mount lifecycle handling with better error recovery and integration test coverage. ([#597](https://github.com/mirendev/runtime/pull/597))
**Bug Fixes**
- **Fixed Dockerfile WORKDIR ignored** - App containers now honor the `WORKDIR` directive from your Dockerfile instead of always defaulting to `/`. ([#617](https://github.com/mirendev/runtime/pull/617))
- **Fixed nested .gitignore files ignored in deploy** - Deploy tarballs now respect `.gitignore` files in subdirectories, not just the root. This prevents things like `web/node_modules/` from inflating your deploy upload. ([#608](https://github.com/mirendev/runtime/pull/608))
- **Fixed disk space GC** - Garbage collection for BuildKit cache and registry blobs now works correctly, preventing disk space from being consumed by stale build artifacts. ([#588](https://github.com/mirendev/runtime/pull/588))
- **Fixed activator pool cache lockout** - The activator pool cache no longer permanently locks out at MaxPoolSize, which was preventing scale-to-zero apps from recovering after hitting the pool size limit. ([#616](https://github.com/mirendev/runtime/pull/616))
- **Fixed disk lease leak in sandbox cleanup** - Periodic sandbox cleanup now properly releases disk leases before deleting sandboxes. ([#613](https://github.com/mirendev/runtime/pull/613))
- **Fixed empty host in route commands** - `miren route set` and `miren route show` now validate that a host is provided instead of accepting empty strings. ([#591](https://github.com/mirendev/runtime/pull/591))
- **Fixed deployment cluster filter** - Deployments are now correctly filtered by cluster, and `miren app history` defaults are improved. ([#589](https://github.com/mirendev/runtime/pull/589))
---
## v0.3.1
*2026-02-06*
**Features**
- **Build-time environment variables** - Environment variables are now available during the build process, so build commands like `npm run build` can access API keys, database URLs, and other configuration. Variables from `app.toml`, existing config, and `--env`/`--secret` CLI flags are all injected at build time. ([#581](https://github.com/mirendev/runtime/pull/581))
**Improvements**
- **Preserve disk mounts during server restart** - The LSVD disk manager now survives server restarts, keeping disk mounts active. Use `systemctl reload miren` for a soft restart that preserves mounts, or `systemctl restart miren` for a full restart. This significantly reduces disruption when updating the miren server. ([#554](https://github.com/mirendev/runtime/pull/554))
**For existing installations:** To enable this feature, either re-run `sudo miren server install --force` to regenerate the systemd unit file, or manually add the following line to `/etc/systemd/system/miren.service` under the `[Service]` section and run `systemctl daemon-reload`:
```
ExecReload=/bin/kill -USR1 $MAINPID
```
- **Batch env var setting** - Setting multiple environment variables now creates a single app version instead of N intermediate versions. ([#578](https://github.com/mirendev/runtime/pull/578))
- **Smarter deploy coalescing** - Rapid successive deploys are now coalesced so only the latest version is launched, avoiding unnecessary churn from intermediate sandbox pools. ([#579](https://github.com/mirendev/runtime/pull/579))
- **Default app name from app.toml** - CLI commands like `app delete`, `route set`, and `route set-default` now infer the app name from `.miren/app.toml` when not specified. ([#562](https://github.com/mirendev/runtime/pull/562))
- **Clearer scaling display** - Instance counts now show `(auto)` or `(fixed)` suffix, and sandbox pool listings include a MODE column. ([#566](https://github.com/mirendev/runtime/pull/566))
- **Deploy validation for services** - Deploys now fail early with a clear error when no services are defined, instead of silently deploying an unservable app. ([#563](https://github.com/mirendev/runtime/pull/563))
- **Smarter `miren upgrade`** - Upgrade now checks permissions upfront, offers interactive sudo vs user-directory install picker, and supports a `--user` flag for non-root installation. ([#564](https://github.com/mirendev/runtime/pull/564))
**Bug Fixes**
- **Fixed build log retention** - Build logs are now properly persisted when using the central BuildKit daemon, restoring the ability to retrieve build output after a deploy with `miren logs --build`. ([#561](https://github.com/mirendev/runtime/pull/561), [#570](https://github.com/mirendev/runtime/pull/570))
- **Fixed orphaned container shims** - Containerd shims are no longer left behind when app containers crash. ([#577](https://github.com/mirendev/runtime/pull/577))
- **Fixed env vars wiped by app.toml** - Adding the first `[[env]]` entry to `app.toml` no longer silently drops existing environment variables. ([#580](https://github.com/mirendev/runtime/pull/580))
- **Fixed DNS during sandbox startup** - DNS no longer returns empty responses during sandbox startup; unknown source IPs are now lazily resolved via entity store lookup. ([#576](https://github.com/mirendev/runtime/pull/576))
- **Fixed nil panic in `miren env list`** - Running `miren env list` when an app isn't deployed to the current cluster no longer crashes. ([#572](https://github.com/mirendev/runtime/pull/572))
---
## v0.3.0
*2026-01-27*
**Features**
- **Automatic image garbage collection** - Container images are now automatically garbage collected to prevent disk exhaustion. Images are kept if less than 30 days old or within the last 20 releases per app. Garbage collection runs weekly or immediately when disk usage exceeds 80%. ([#544](https://github.com/mirendev/runtime/pull/544))
- **Deploy-time environment variables** - Set environment variables atomically with your deployment using `miren deploy -e KEY=VALUE` or `-s SECRET=value` for sensitive values. Supports reading from files with `@file` syntax and interactive prompts. ([#521](https://github.com/mirendev/runtime/pull/521))
- **Graceful shutdown during redeploy** - Apps now receive proper graceful shutdown time during redeploy instead of being force-killed. Configure per-service with `shutdown_timeout` in app.toml (default 10 seconds). ([#520](https://github.com/mirendev/runtime/pull/520))
- **`miren deploy cancel`** - Cancel stuck in-progress deployments without waiting for the 30-minute lock expiry. ([#517](https://github.com/mirendev/runtime/pull/517))
- **`miren debug bundle`** - New diagnostic command that collects system info, logs, container state, and process info into a tar.gz archive for troubleshooting. ([#531](https://github.com/mirendev/runtime/pull/531))
- **Domain allow list for TLS** - Automatic TLS certificate provisioning is now restricted to domains with explicitly configured routes, preventing certificate issuance for arbitrary domains pointed at the server. ([#542](https://github.com/mirendev/runtime/pull/542))
**Improvements**
- **Better app history display** - The `miren app history` command now shows deployment IDs (useful for `deploy cancel`) and improved status formatting. ([#529](https://github.com/mirendev/runtime/pull/529), [#532](https://github.com/mirendev/runtime/pull/532))
**Bug Fixes**
- **Fixed server restart killing all sandboxes** - Sandbox recovery no longer incorrectly kills all sandboxes when the server restarts. ([#546](https://github.com/mirendev/runtime/pull/546))
- **Fixed disk lease transfers** - Disk leases are now properly released when sandboxes stop, allowing new sandboxes to acquire them. ([#516](https://github.com/mirendev/runtime/pull/516))
- **Fixed sandbox exec issues** - Fixed panic when running `sandbox exec` without a TTY and fixed wrong entrypoint being applied to service containers. ([#515](https://github.com/mirendev/runtime/pull/515), [#518](https://github.com/mirendev/runtime/pull/518))
- **Fixed deployment panic handling** - Panics during deployment are now properly marked as failed instead of leaving deployments stuck. ([#513](https://github.com/mirendev/runtime/pull/513))
- **Fixed WebSocket 502 errors** - WebSocket connections no longer fail with 502 Bad Gateway. ([#507](https://github.com/mirendev/runtime/pull/507))
- **Fixed cluster switch for multi-identity setups** - Improved identity handling and UX when switching between clusters. ([#535](https://github.com/mirendev/runtime/pull/535))
---
## v0.2.1
*2025-12-19*
**Improvements**
- **Improved `miren doctor`** - The diagnostic command now suggests commands for you to run instead of running them automatically. Includes better guidance for config, auth, and server issues, plus clickable routes in app listings. ([#491](https://github.com/mirendev/runtime/pull/491))
- **Smarter install defaults** - `miren server install` and `miren download` now default to the version matching your binary instead of always using `main`. ([#500](https://github.com/mirendev/runtime/pull/500), [#502](https://github.com/mirendev/runtime/pull/502))
**Bug Fixes**
- **Fixed scale-to-zero pool deletion** - Pools for apps at scale-to-zero are no longer prematurely deleted after being idle for an hour, which was causing "pool has reached maximum size" errors when traffic resumed. ([#497](https://github.com/mirendev/runtime/pull/497))
- **Fixed activator cache cleanup** - The activator now proactively cleans up cached pool references when pools are deleted, preventing stale cache errors. ([#498](https://github.com/mirendev/runtime/pull/498))
- **Fixed disk debug commands** - `miren debug disk status` and related commands now correctly parse disk IDs. ([#499](https://github.com/mirendev/runtime/pull/499))
---
## v0.2.0
*2025-12-17*
**Features**
- **`miren app run`** - Run commands in a one-off sandbox with your app's configuration. Great for debugging, migrations, or one-off tasks. ([#489](https://github.com/mirendev/runtime/pull/489))
- **Persistent BuildKit daemon** - Builds are now significantly faster thanks to a persistent BuildKit daemon that maintains layer caching across builds. No more cold starts! ([#490](https://github.com/mirendev/runtime/pull/490))
- **`miren doctor` command** - New diagnostic command to help troubleshoot your Miren setup. Includes `miren doctor apps` to check app status and `miren doctor auth` to verify authentication. ([#484](https://github.com/mirendev/runtime/pull/484))
- **`miren deploy --analyze`** - Preview what Miren will detect about your app before actually building it. Great for understanding how your project will be configured. ([#485](https://github.com/mirendev/runtime/pull/485))
- **Rust and uv support** - Miren now auto-detects Rust projects and Python projects using uv, and builds them appropriately. ([#485](https://github.com/mirendev/runtime/pull/485))
- **Log filtering** - Filter logs by service name with `miren logs --service ` and by content with `miren logs -g `. Also includes a faster chunked log streaming API under the hood. ([#487](https://github.com/mirendev/runtime/pull/487), [#466](https://github.com/mirendev/runtime/pull/466))
- **Debug networking commands** - New `miren debug netdb` commands for inspecting IP allocations and cleaning up orphaned leases. Helpful for advanced troubleshooting. ([#478](https://github.com/mirendev/runtime/pull/478))
**Bug Fixes**
- **Fixed IP address leaks** - Resolved several issues where IP addresses could leak during sandbox lifecycle events, container cleanup, and entity patch failures. ([#479](https://github.com/mirendev/runtime/pull/479))
- **Fixed stale pool reference** - Deleting and recreating an IP pool no longer causes "error acquiring lease" failures. ([#483](https://github.com/mirendev/runtime/pull/483))
- **Fixed LSVD write handling** - LSVD now uses proper Go file writes instead of raw unix calls, improving reliability. ([#477](https://github.com/mirendev/runtime/pull/477))
- **Fixed deployment cancellation race** - Cancelling a deploy with Ctrl-C no longer causes a race condition between the main and UI goroutines. ([#482](https://github.com/mirendev/runtime/pull/482))
- **Fixed authentication bypass** - Local/non-cloud mode now properly requires client certificates. ([#469](https://github.com/mirendev/runtime/pull/469))
- **Fixed entity revision check** - Entity patches no longer incorrectly enforce revision checks when `fromRevision` is 0. ([#470](https://github.com/mirendev/runtime/pull/470))
- **Fixed IPv6 environments** - VictoriaMetrics and VictoriaLogs now listen on IPv6, fixing issues in environments with IPv6 enabled. ([#481](https://github.com/mirendev/runtime/pull/481))
**Documentation**
- Updated system requirements to 4GB RAM and 20GB disk ([#480](https://github.com/mirendev/runtime/pull/480))
- Improved getting started documentation ([#471](https://github.com/mirendev/runtime/pull/471))
- Fixed missing pages in docs sidebar navigation ([#467](https://github.com/mirendev/runtime/pull/467))
---
## v0.1.0
*2025-12-09*
Initial preview release.
---
## CI/CD Deployment
Deploy to Miren from CI/CD pipelines without storing long-lived secrets. Instead of managing API keys or certificates, your CI system presents a short-lived OIDC identity token that Miren validates directly with the identity provider.
GitHub Actions works out of the box. Other OIDC-capable CI systems (GitLab CI, CircleCI, etc.) are supported with manual configuration.
:::info[Looking for route protection?]
This page covers OIDC for **CI/CD deployment authentication** — letting pipelines deploy without stored secrets. For putting single sign-on in front of your **application's HTTP routes**, see [Protecting Routes](./route-protect.md).
:::
## Minimum working example
One-time setup — export the cluster address (store the output as a GitHub Actions secret named `MIREN_CLUSTER`) and allow the repository to deploy:
```miren
miren cluster export-address
miren auth ci add -a myapp --github acme/web-app
```
Then add `.github/workflows/deploy.yml`:
```yaml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mirendev/actions/deploy@main
with:
cluster: ${{ secrets.MIREN_CLUSTER }}
app: myapp
```
Every push to `main` now deploys, authenticated by a short-lived OIDC token — no deploy secrets stored anywhere. The [Quick Start](#quick-start-with-github-actions) below walks through each piece.
## How It Works
1. **Create an OIDC binding** on your Miren cluster that links an app to an identity provider and the claims that identify your repository.
2. **Your CI job runs** and the Miren CLI auto-detects the CI environment (e.g., GitHub Actions sets `ACTIONS_ID_TOKEN_REQUEST_URL`).
3. **The CLI requests a short-lived token** from the CI provider's OIDC endpoint, with the cluster hostname as the audience.
4. **Miren validates the token** by fetching the provider's OIDC discovery document and JWKS keys, then checks that the token's subject and claims match a configured binding.
No secrets are stored in your CI system. The OIDC token is issued fresh for each job and expires in minutes.
:::tip[Per-PR preview deploys]
Pairing OIDC with [Pull Request Environments](./pr-environments.md) lets each PR get its own short-lived preview URL — see that page for the full workflow.
:::
## Quick Start with GitHub Actions
### Step 1: Get Your Cluster Address
On a machine where you're already authenticated with the cluster, export the cluster address for use in CI:
```miren
miren cluster export-address
```
This outputs a string like:
```
miren.example.com:8443;sha1:a1b2c3d4e5f6...
```
The value includes the cluster address and a TLS certificate fingerprint for verification. Store this as a GitHub Actions secret named `MIREN_CLUSTER`.
### Step 2: Add an OIDC Binding
Create a binding that allows your GitHub repository to deploy:
```miren
miren auth ci add -a myapp --github acme/web-app
```
This creates a binding with:
- **Issuer:** `https://token.actions.githubusercontent.com`
- **Repository:** `acme/web-app`, owned by `acme` (matches all branches)
- **Allowed events:** `push,workflow_dispatch` (by default)
You can restrict further — see [Restricting Access](#restricting-access) below.
### Step 3: Add the GitHub Actions Workflow
```yaml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # Required — allows the job to request an OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
uses: mirendev/actions/deploy@main
with:
cluster: ${{ secrets.MIREN_CLUSTER }}
app: myapp
```
The key pieces:
- **`permissions.id-token: write`** — tells GitHub to make an OIDC token available to the job.
- **`mirendev/actions/deploy`** — installs the Miren CLI, connects to the cluster (verifying the TLS certificate fingerprint), and runs `miren deploy`. The `cluster` input takes the value from `miren cluster export-address`.
- The CLI detects `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` in the environment, requests an OIDC token with the cluster hostname as the audience, and sends it as a bearer token.
### Alternative: Using a Config File
Instead of `MIREN_CLUSTER`, you can provide a minimal `clientconfig.yaml` with no credentials:
```yaml
active_cluster: production
clusters:
production:
hostname: miren.example.com:8443
```
Commit this to your repository (e.g., as `.miren/clientconfig.yaml`) and set `MIREN_CONFIG: .miren/clientconfig.yaml` in your workflow. The CLI will auto-detect OIDC the same way. `MIREN_CLUSTER` is preferred because it also verifies the TLS certificate fingerprint and requires no file in your repository.
## Restricting Access
The `--github` shorthand provides sensible defaults, but you can tighten access further.
### Restricting to a Branch
The `--github` shorthand accepts any ref from the repository, for the allowed events. To narrow it to a single branch, use `--allowed-refs`:
```miren
miren auth ci add -a myapp --github acme/web-app \
--allowed-refs "refs/heads/main"
```
:::warning[Globs match across slashes]
`*` matches any characters including `/`, unlike standard path matching. `?` matches a single character.
:::
### A Note on Subject Patterns
Bindings created with `--github` match on the token's `repository` and `repository_owner` claims rather than on its subject. This is deliberate. As of [GitHub's immutable subject claims change](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/), any repository created, renamed, or transferred after July 15, 2026 sends a subject with numeric IDs embedded in it:
```text
repo:acme@1234567/web-app@7654321:ref:refs/heads/main
```
A pattern written against the older `repo:acme/web-app:*` format can never match that, and GitHub recommends binding policies to repository metadata instead of parsing the subject. The `repository` claims are unaffected by the change and work under either format.
You can still set `--subject` explicitly for a provider that needs it, and it will be used as-is. If you have a binding created before this change that stopped working, re-run `miren auth ci add --github OWNER/REPO` to replace it.
:::warning[Upgrade the server first]
Older servers require a subject pattern and will reject a binding created by a newer CLI. Upgrade the server before creating or replacing a GitHub binding. Existing bindings keep working either way.
:::
### Allowed Events
By default, only `push` and `workflow_dispatch` events are permitted. To also allow `pull_request` events:
```miren
miren auth ci add -a myapp --github acme/web-app \
--allowed-events push,workflow_dispatch,pull_request
```
### Allowed Refs
Restrict deployments to specific git refs:
```miren
miren auth ci add -a myapp --github acme/web-app \
--allowed-refs "refs/heads/main,refs/tags/v*"
```
### Claim Conditions
Claim conditions (events and refs) use the same glob pattern syntax as subject patterns. Comma-separated values mean "match any one of these alternatives."
## What OIDC Callers Can Do
OIDC-authenticated callers have a scoped set of permissions. They can:
- **Deploy** — create and manage deployments, deploy new versions
- **Build** — build from source, analyze apps
- **Read logs** — stream and view application logs
- **View app status** — check app info and configuration
- **Report telemetry** — send trace spans
OIDC callers **cannot** perform administrative operations like deleting apps, managing clusters, modifying routes, or accessing other apps.
## Other CI Platforms
For CI systems other than GitHub Actions, use the `--issuer` and `--subject` flags to create a binding:
```miren
miren auth ci add -a myapp \
--issuer https://gitlab.com \
--subject "project_path:acme/web-app:ref_type:branch:ref:main"
```
The CLI auto-detects OIDC tokens in GitHub Actions. For other platforms, you can adapt the following script which replicates what the GitHub Actions do — downloading the CLI, connecting to the cluster, and deploying:
```bash
#!/usr/bin/env bash
set -euo pipefail
# --- Configuration (set these in your CI environment) ---
# MIREN_CLUSTER: cluster address with TLS fingerprint (from `miren cluster export-address`)
# MIREN_APP: application name to deploy
: "${MIREN_CLUSTER:?MIREN_CLUSTER must be set}"
: "${MIREN_APP:?MIREN_APP must be set}"
# 1. Install the Miren CLI
MIREN_VERSION="${MIREN_VERSION:-latest}"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH=amd64 ;;
aarch64) ARCH=arm64 ;;
esac
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
if [ "$MIREN_VERSION" = "latest" ]; then
DOWNLOAD_URL="https://api.miren.cloud/assets/release/miren/latest/miren-${OS}-${ARCH}.zip"
else
DOWNLOAD_URL="https://api.miren.cloud/assets/release/miren/${MIREN_VERSION}/miren-${OS}-${ARCH}.zip"
fi
curl -fLO "$DOWNLOAD_URL"
unzip -q "miren-${OS}-${ARCH}.zip"
install -m 755 miren /usr/local/bin/
miren version
# 2. Deploy
# MIREN_CLUSTER and MIREN_APP are read from the environment automatically.
# In GitHub Actions, the CLI auto-detects the OIDC token. For other CI systems,
# you'll need to acquire an OIDC token from your provider and set
# ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, or
# pass the token through your platform's native mechanism.
miren deploy
```
Each CI platform has its own way of issuing OIDC tokens — check your provider's documentation for details on obtaining one.
## Managing Bindings
### List Bindings
```miren
miren auth ci list -a myapp
```
Example output:
```
ID PROVIDER ISSUER SUBJECT CONDITIONS
abc123 github https://token.actions.githubusercontent.com repository=acme/web-app; repository_owner=acme; event_name=push,workflow_dispatch
def456 generic https://gitlab.com project_path:acme/web-*
```
### Remove a Binding
```miren
miren auth ci remove abc123
```
## CLI Reference
### `miren auth ci add`
Create an OIDC binding for an application.
| Flag | Description |
|------|-------------|
| `-a, --app` | Application name (required) |
| `--github OWNER/REPO` | GitHub shorthand — sets issuer, provider, and repository claim conditions automatically |
| `--issuer URL` | OIDC issuer URL (required if `--github` is not used) |
| `--subject PATTERN` | Glob pattern for the token subject claim |
| `--allowed-events EVENTS` | Comma-separated event names to allow (default with `--github`: `push,workflow_dispatch`) |
| `--allowed-refs PATTERN` | Glob pattern for allowed git refs |
| `--description TEXT` | Human-readable description of this binding |
Either `--github` or `--issuer` is required.
### `miren auth ci list`
List OIDC bindings for an application.
| Flag | Description |
|------|-------------|
| `-a, --app` | Application name (required) |
### `miren auth ci remove`
Remove an OIDC binding by ID.
```miren
miren auth ci remove
```
### `miren cluster export-address`
Export a cluster address with its TLS certificate fingerprint, for use with the `MIREN_CLUSTER` environment variable.
```miren
# Export the active cluster
miren cluster export-address
# Export a specific cluster
miren cluster export-address -C my-cluster
```
Output format: `address:port;sha1:fingerprint`
## Environment Variables
| Variable | Description |
|----------|-------------|
| `MIREN_CLUSTER` | Cluster address with optional TLS fingerprint (`address:port;sha1:fingerprint`). The CLI connects directly — no config file needed. Can also be a cluster name from an existing config. |
| `MIREN_APP` | Target application name. Equivalent to `-a myapp` on commands. |
| `MIREN_CONFIG` | Path to a `clientconfig.yaml` file. Alternative to `MIREN_CLUSTER` when you need a config file. |
## Troubleshooting
### "OIDC token request failed" in GitHub Actions
Ensure your workflow has `permissions.id-token: write`. Without this, GitHub does not set the `ACTIONS_ID_TOKEN_REQUEST_URL` environment variable and the CLI cannot request a token.
```yaml
permissions:
id-token: write
contents: read
```
### "OIDC access denied" or token not matching any binding
When a token is rejected because it didn't match a binding, the CLI reports the subject and repository it saw, so start by comparing those against `miren auth ci list -a myapp`. Other authentication failures stay generic. Then check that:
- The binding's `repository` condition names the repo the workflow is running in. A binding created for a different repo, or one left over from before a rename, won't match.
- If the binding has a subject pattern instead of repository conditions, check that the pattern matches the subject in the error. A name-based pattern like `repo:OWNER/REPO:*` can't match a repo created, renamed, or transferred after July 15, 2026, since those send numeric IDs in the subject (see [GitHub's immutable subject claims change](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/)). Re-run `miren auth ci add --github OWNER/REPO` to replace it with one that doesn't depend on the subject format.
- The allowed events include the event type that triggered the workflow (e.g., `push`, `pull_request`).
- The allowed refs, if set, cover the branch or tag being deployed.
### CLI not using OIDC (falling back to other auth)
OIDC auto-detection only activates when **all** of these are true:
1. The cluster config has no `identity` field.
2. The cluster config has `cloud_auth: false` (or the field is absent).
3. The environment variables `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` are both set.
Using `MIREN_CLUSTER` satisfies conditions 1 and 2 automatically since the auto-created cluster config has no identity or cloud_auth. If you use a `clientconfig.yaml` instead, make sure it doesn't have those fields set.
### Token audience mismatch
The CLI automatically sets the token audience to the cluster hostname. Ensure the address in `MIREN_CLUSTER` (or `hostname` in your config file) exactly matches the server's hostname, including port if non-standard.
### TLS certificate fingerprint mismatch
If you see `TLS certificate fingerprint mismatch`, the server's certificate has changed since you ran `miren cluster export-address`. Re-run the export command and update your `MIREN_CLUSTER` secret with the new value.
---
## miren addon create
Attach an addon to an application
Attaching an addon provisions the backing resource and injects its connection details as environment variables into your app. Once provisioning completes, Miren creates a new app version with those variables and rolls it out automatically — you do not need to run `miren deploy` or `miren app restart`. The rollout is deferred until the addon finishes provisioning, so it may not be immediate.
## Usage
```bash
miren addon create [flags]
```
## Arguments
- `spec` — Addon spec (e.g., miren-postgresql:small)
## Flags
- `--version, -V` — Software version or full image reference (e.g., 16, or registry.example.com/postgres:16-custom)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Attach a PostgreSQL addon:**
```bash
miren addon create miren-postgresql:small
```
**Attach a PostgreSQL addon with a specific version:**
```bash
miren addon create miren-postgresql:small --version 16
```
## See also
- [`miren addon`](./addon.md)
---
## miren addon destroy
Remove an addon from an application
Removing an addon deprovisions the backing resource and strips its injected environment variables from your app. Miren creates a new app version without those variables and rolls it out automatically — you do not need to run `miren deploy` or `miren app restart`.
## Usage
```bash
miren addon destroy [flags]
```
## Arguments
- `name` — Addon name (e.g., miren-postgresql)
## Flags
- `--force, -f` — Skip confirmation prompt
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove an addon:**
```bash
miren addon destroy miren-postgresql
```
**Remove without confirmation:**
```bash
miren addon destroy miren-postgresql --force
```
## See also
- [`miren addon`](./addon.md)
---
## miren addon list-available
List available addons
## Usage
```bash
miren addon list-available [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List available addons:**
```bash
miren addon list-available
```
## See also
- [`miren addon`](./addon.md)
---
## miren addon list
List addons attached to an application
## Usage
```bash
miren addon list [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List addons for the current app:**
```bash
miren addon list
```
## See also
- [`miren addon`](./addon.md)
---
## miren addon rotate
Rotate an addon's backing credential
Rotating an addon credential generates a new secret, applies it to the running backing engine, and updates the value Miren stores. Consuming apps that embed the credential are redeployed automatically to pick up the new connection details — you do not need to run `miren deploy` or `miren app restart`. Rotation runs asynchronously: the command returns a request id and the rotation controller carries it out.
## Usage
```bash
miren addon rotate [flags]
```
## Arguments
- `name` — Addon name (e.g., miren-valkey)
## Flags
- `--credential` — Which credential to rotate (provider-specific; defaults to the addon's primary credential)
- `--force, -f` — Skip confirmation prompt
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rotate a Valkey addon's password:**
```bash
miren addon rotate miren-valkey
```
**Rotate without confirmation:**
```bash
miren addon rotate miren-valkey --force
```
## See also
- [`miren addon`](./addon.md)
---
## miren addon variants
Show variants for an addon
## Usage
```bash
miren addon variants [flags]
```
## Arguments
- `addon` — Addon name (e.g., miren-postgresql)
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show variants for PostgreSQL:**
```bash
miren addon variants miren-postgresql
```
## See also
- [`miren addon`](./addon.md)
---
## miren addon
Addon management commands
## Usage
```bash
miren addon [flags]
```
## Subcommands
- [`miren addon create`](./addon-create.md) — Attach an addon to an application
- [`miren addon destroy`](./addon-destroy.md) — Remove an addon from an application
- [`miren addon list`](./addon-list.md) — List addons attached to an application
- [`miren addon list-available`](./addon-list-available.md) — List available addons
- [`miren addon rotate`](./addon-rotate.md) — Rotate an addon's backing credential
- [`miren addon variants`](./addon-variants.md) — Show variants for an addon
---
## miren admin
Call an admin method on an application
The admin interface allows you to execute custom administrative functions in your running application—useful for user management, cache clearing, database operations, and other maintenance tasks.
## Listing Methods
Use the `--list` flag to discover what admin methods your application exposes:
```bash
$ miren admin --list
Admin methods for go-admin
clear-cache
│ Clear the application cache
delete-user
│ Delete a user by ID
└ user_id string
get-stats
│ Get application statistics
get-user
│ Get a specific user by ID
└ user_id string
list-users
│ List all users in the system
├ limit number
└ offset number
Usage: miren admin -a go-admin [key=value ...]
```
## Parameter Validation
By default, `miren admin` validates method names and parameters against the application's introspection data before making the call. This catches typos and missing required parameters early.
To skip validation (e.g., if your app doesn't support introspection), use `--no-validate`:
```bash
miren admin --no-validate some-method
```
## Output Formats
The admin command automatically chooses the output format based on context:
- **TTY (terminal)**: Uses a human-friendly pretty format by default
- **Non-TTY (pipes, scripts)**: Uses highlighted JSON by default
You can override this behavior:
```bash
# Force JSON output for scripting
miren admin --json get-stats | jq '.total_users'
# Force pretty output even when piping
miren admin --pretty get-user user_id=123
```
## Error Handling
If the admin call fails, the command exits with a non-zero status and displays the error:
```bash
$ miren admin get-user user_id=nonexistent
admin call failed (code -32001): user not found
```
Error codes follow JSON-RPC conventions:
- `-32700`: Parse error
- `-32600`: Invalid request
- `-32601`: Method not found
- `-32602`: Invalid params
- `-32603`: Internal error
- Custom codes (negative numbers): Application-specific errors
## Usage
```bash
miren admin [method] [args...] [flags]
```
## Arguments
- `method` — Admin method to call
## Flags
- `--func-help` — Show help for a specific admin method
- `--json, -j` — Output as highlighted JSON (default for non-TTY)
- `--list, -l` — List available admin methods
- `--no-validate` — Skip method/parameter validation
- `--params-file, -f` — Read params as JSON from file (use - for stdin)
- `--pretty, -p` — Render output in a human-friendly format (default for TTY)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List available admin methods:**
```bash
miren admin --list -a myapp
```
**Call an admin method:**
```bash
miren admin health -a myapp
```
**Call a method with JSON output:**
```bash
miren admin stats -a myapp --json
```
**Call a method with params from a file:**
```bash
miren admin migrate -a myapp -f params.json
```
---
## miren alias list
List configured CLI aliases
## Usage
```bash
miren alias list [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren alias`](./alias.md)
---
## miren alias
CLI alias management
## Usage
```bash
miren alias [flags]
```
## Subcommands
- [`miren alias list`](./alias-list.md) — List configured CLI aliases
---
## miren app attach
Attach to a running task
Joins your terminal to a task run that is already going.
Attaching and detaching are invisible to the run itself: it is started by the platform and outlives any client. So you can attach to a run started with `--detach`, reattach after a dropped connection, or never attach at all.
Several clients can attach to one run at once; they share the same terminal.
Press **Ctrl-P Ctrl-Q** to detach again, leaving the run going.
:::note[Attaching to a finished run]
Only runs whose command is still executing can be attached. For one that has finished, read its output back with `miren logs run`.
:::
## Usage
```bash
miren app attach [flags]
```
## Arguments
- `run` — Run to attach to
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rejoin a run's terminal:**
```bash
miren app attach run/myapp-session-4kq2np
```
## See also
- [`miren app`](./app.md)
---
## miren app delete
Delete an application and all its resources
## Usage
```bash
miren app delete [appname] [flags]
```
## Arguments
- `appname` — Name of the app to delete
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force, -f` — Force delete without confirmation
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Delete an app (with confirmation prompt):**
```bash
miren app delete myapp
```
**Delete without confirmation:**
```bash
miren app delete myapp --force
```
## See also
- [`miren app`](./app.md)
---
## miren app history
Show deployment history for an application
## Usage
```bash
miren app history [flags]
```
## Flags
- `--all` — Show all deployments (ignore limit)
- `--detailed` — Show all columns including git information
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--limit, -n` — Maximum number of deployments to show (default: `10`)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show deployment history:**
```bash
miren app history
```
**Show detailed history with git info:**
```bash
miren app history --detailed
```
## See also
- [`miren app`](./app.md)
---
## miren app list
List all applications
## Usage
```bash
miren app list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all apps:**
```bash
miren app list
```
**List apps as JSON:**
```bash
miren app list --format json
```
## See also
- [`miren app`](./app.md)
---
## miren app restart
Restart an application
Restart stops your app's running sandboxes and lets the pool manager re-create them from the *current* active version. It does not create a new version, change any configuration, or select or build another image. The app comes back on exactly the spec it was already running.
Use restart to:
- Clear stuck or wedged process state
- Reset the crash-loop cooldown so a crashing app is retried immediately
- Pick up data restored out-of-band (for example, after `miren disk restore`)
:::note[Config and env changes restart on their own]
You do not need to restart after `miren env set`, `miren env delete`, `miren addon create`, or `miren addon destroy`. Each of those already creates a new version and rolls out new sandboxes automatically. A manual restart on top only adds a redundant rollout.
:::
## Usage
```bash
miren app restart [flags]
```
## Flags
- `--service, -s` — Restart only a specific service
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Restart the current app:**
```bash
miren app restart
```
**Restart a specific service:**
```bash
miren app restart -s web
```
## See also
- [`miren app`](./app.md)
---
## miren app run
Open interactive shell in a new sandbox
This command runs a command in a fresh sandbox built from your app's active version — the same image, environment variables, and working directory as your deployed app — and connects your terminal to it.
With no arguments it opens an interactive shell. With arguments it runs that command. With `--task` it runs a task declared in `app.toml`.
This is useful for:
- Debugging application issues in an isolated environment
- Running one-off commands with your app's configuration
- Running a declared task by hand, such as re-running a migration without redeploying
- Exploring the container filesystem
### How It Works
1. Miren records a **run**: a durable entity holding what was executed, by whom, and how it ended
2. The run controller builds a sandbox from your app's active version and starts the command
3. Your terminal attaches to that command
4. On exit, the command's exit code is recorded and propagated to your shell, and the sandbox is torn down
### Runs outlive your terminal
Disconnecting is not cancelling. If your connection drops, the command keeps going — reattach with `miren app attach`, read its output with `miren logs run`, or leave it and let its timeout reap it.
Press **Ctrl-P Ctrl-Q** to leave a run you are attached to. It keeps running.
`--detach` therefore means exactly one thing: don't attach my terminal right now. Use `miren app runs cancel` to actually stop a run.
:::note[Ctrl-C goes to your command]
Your keystrokes reach the command, so Ctrl-C interrupts *it* rather than this CLI — the same as pressing it in a local shell. That is why leaving needs its own sequence.
:::
:::tip[Sandboxes are per-invocation]
The sandbox is per-invocation. Any changes you make (files created, packages installed) are discarded when it ends.
:::
:::note[Reaching an existing sandbox]
Runs are recorded, so `miren app runs` shows what has been executed against an app and how it ended. To reach into an already-running production sandbox instead, use `miren sandbox exec`.
:::
## Usage
```bash
miren app run [args...] [flags]
```
## Flags
- `--detach` — Create the run without attaching a terminal
- `--task` — Task to run; defaults to the console task
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Open a shell in your app's environment:**
```bash
miren app run
```
**Run a specific command:**
```bash
miren app run -- bin/rails console
```
**Run database migrations:**
```bash
miren app run -- bin/rails db:migrate
```
**Run a declared task:**
```bash
miren app run --task reindex
```
**Start a task without holding a terminal:**
```bash
miren app run --task reindex --detach
```
## See also
- [`miren app`](./app.md)
---
## miren app runs cancel
End a run early
## Usage
```bash
miren app runs cancel [flags]
```
## Arguments
- `run` — Run to cancel
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Cancel a run:**
```bash
miren app runs cancel run/myapp-reindex-4kq2np
```
## See also
- [`miren app runs`](./app-runs.md)
---
## miren app runs
List recent task runs
## Usage
```bash
miren app runs [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--limit, -n` — Maximum runs to show (default: `0`)
- `--task` — Only show runs of this task
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show recent runs:**
```bash
miren app runs
```
**Show runs of one task as JSON:**
```bash
miren app runs --task migrate --format json
```
## Subcommands
- [`miren app runs cancel`](./app-runs-cancel.md) — End a run early
## See also
- [`miren app`](./app.md)
---
## miren app set-workload-role
Set the API role for an app's sandbox identity tokens
## Usage
```bash
miren app set-workload-role [args...] [flags]
```
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Let an app's workloads read and deploy their own app:**
```bash
miren app set-workload-role -a myapp app-deployer
```
**Grant a cluster-wide read role (operator only):**
```bash
miren app set-workload-role -a tooling cluster-readonly
```
## See also
- [`miren app`](./app.md)
---
## miren app status
Show current status of an application
## Usage
```bash
miren app status [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show status for the current app:**
```bash
miren app status
```
**Show status for a specific app:**
```bash
miren app status -a myapp
```
## See also
- [`miren app`](./app.md)
---
## miren app versions
List app versions with status
## Usage
```bash
miren app versions [flags]
```
## Flags
- `--ephemeral` — Show only ephemeral versions
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--limit, -n` — Max versions to show (default: `20`)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all versions:**
```bash
miren app versions
```
**List only ephemeral versions:**
```bash
miren app versions --ephemeral
```
## See also
- [`miren app`](./app.md)
---
## miren app
Get information about an application
## Usage
```bash
miren app [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--graph, -g` — Graph the app stats
- `--json` — Shorthand for --format json
- `--watch, -w` — Watch the app stats
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show app info for the current directory:**
```bash
miren app
```
**Show info for a specific app:**
```bash
miren app -a myapp
```
**Watch app stats in real time:**
```bash
miren app --watch
```
## Subcommands
- [`miren app attach`](./app-attach.md) — Attach to a running task
- [`miren app delete`](./app-delete.md) — Delete an application and all its resources
- [`miren app history`](./app-history.md) — Show deployment history for an application
- [`miren app list`](./app-list.md) — List all applications
- [`miren app restart`](./app-restart.md) — Restart an application
- [`miren app run`](./app-run.md) — Open interactive shell in a new sandbox
- [`miren app runs`](./app-runs.md) — List recent task runs
- [`miren app set-workload-role`](./app-set-workload-role.md) — Set the API role for an app's sandbox identity tokens
- [`miren app status`](./app-status.md) — Show current status of an application
- [`miren app versions`](./app-versions.md) — List app versions with status
---
## miren apps
List all applications (alias for 'app list')
## Usage
```bash
miren apps [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all apps:**
```bash
miren apps
```
---
## miren auth ci add
Add a CI authentication binding to an application
## Usage
```bash
miren auth ci add [flags]
```
## Flags
- `--allowed-events` — Comma-separated event names to allow (default: push,workflow_dispatch)
- `--allowed-refs` — Glob pattern for allowed git refs
- `--description` — Human-readable description of this binding
- `--github` — GitHub owner/repo shorthand (sets issuer, provider, and repository claim conditions)
- `--issuer` — OIDC issuer URL
- `--subject` — Glob pattern for the token subject
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren auth ci`](./auth-ci.md)
---
## miren auth ci list
List CI authentication bindings for an application
## Usage
```bash
miren auth ci list [flags]
```
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren auth ci`](./auth-ci.md)
---
## miren auth ci remove
Remove a CI authentication binding
## Usage
```bash
miren auth ci remove [flags]
```
## Arguments
- `id` — ID of the CI authentication binding to remove
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren auth ci`](./auth-ci.md)
---
## miren auth ci
CI authentication binding management
## Usage
```bash
miren auth ci [flags]
```
## Subcommands
- [`miren auth ci add`](./auth-ci-add.md) — Add a CI authentication binding to an application
- [`miren auth ci list`](./auth-ci-list.md) — List CI authentication bindings for an application
- [`miren auth ci remove`](./auth-ci-remove.md) — Remove a CI authentication binding
## See also
- [`miren auth`](./auth.md)
---
## miren auth generate
Generate authentication config file
## Usage
```bash
miren auth generate [flags]
```
## Flags
- `--cluster-name, -C` — Name of the cluster (default: `local`)
- `--config-path, -c` — Path to the config file, - for stdout (default: `clientconfig.yaml`)
- `--data-path, -d` — Data path (default: `/var/lib/miren`)
- `--name, -n` — Name of the client certificate (default: `miren-user`)
- `--public-ip, -p` — Use public IP for the target, if available
- `--target, -t` — Hostname to embed in the config (default: `localhost`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Generate auth config:**
```bash
miren auth generate
```
## See also
- [`miren auth`](./auth.md)
---
## miren auth provider add github
Add a GitHub identity provider
## Usage
```bash
miren auth provider add github [flags]
```
## Arguments
- `name` — Name for this identity provider
## Flags
- `--client-id` — GitHub OAuth app client ID
- `--client-secret` — GitHub OAuth app client secret
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--org` — GitHub org restriction (repeatable). Use "name" for any-member, or "name:team1,team2" to require team membership and populate X-User-Groups.
- `--update` — Overwrite an existing provider with the same name (rotates client secret)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Add a GitHub provider scoped to a team:**
```bash
miren auth provider add github my-gh \
--client-id $CLIENT_ID \
--client-secret $CLIENT_SECRET \
--org mirendev:platform,eng
```
## See also
- [`miren auth provider add`](./auth-provider-add.md)
---
## miren auth provider add oidc
Add an OIDC identity provider
## Usage
```bash
miren auth provider add oidc [flags]
```
## Arguments
- `name` — Name for this identity provider
## Flags
- `--client-id` — OAuth2 client ID
- `--client-secret` — OAuth2 client secret
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--provider-url` — OIDC provider URL (e.g., https://accounts.google.com)
- `--scope` — OAuth2 scopes (can be specified multiple times)
- `--update` — Overwrite an existing provider with the same name (rotates client secret)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Add a Google OIDC provider:**
```bash
miren auth provider add oidc my-google \
--provider-url https://accounts.google.com \
--client-id $CLIENT_ID \
--client-secret $CLIENT_SECRET \
--scope email --scope profile
```
## See also
- [`miren auth provider add`](./auth-provider-add.md)
---
## miren auth provider add password
Add a shared-password identity provider
## Usage
```bash
miren auth provider add password [flags]
```
## Arguments
- `name` — Name for this password provider
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--password` — Password (omit to prompt interactively, use @file to read from file)
- `--update` — Overwrite an existing provider with the same name (rotates password)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Add a password provider:**
```bash
miren auth provider add password my-pw --password hunter2
```
## See also
- [`miren auth provider add`](./auth-provider-add.md)
---
## miren auth provider add
Add an identity provider for route protection
## Usage
```bash
miren auth provider add [flags]
```
## Subcommands
- [`miren auth provider add github`](./auth-provider-add-github.md) — Add a GitHub identity provider
- [`miren auth provider add oidc`](./auth-provider-add-oidc.md) — Add an OIDC identity provider
- [`miren auth provider add password`](./auth-provider-add-password.md) — Add a shared-password identity provider
## See also
- [`miren auth provider`](./auth-provider.md)
---
## miren auth provider list
List identity providers
## Usage
```bash
miren auth provider list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren auth provider`](./auth-provider.md)
---
## miren auth provider remove
Remove an identity provider
## Usage
```bash
miren auth provider remove [flags]
```
## Arguments
- `name` — Name of the identity provider to remove
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force` — Remove the provider even if it is attached to routes
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren auth provider`](./auth-provider.md)
---
## miren auth provider show
Show an identity provider
## Usage
```bash
miren auth provider show [flags]
```
## Arguments
- `name` — Name of the identity provider
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren auth provider`](./auth-provider.md)
---
## miren auth provider
Identity provider management
## Usage
```bash
miren auth provider [flags]
```
## Subcommands
- [`miren auth provider add`](./auth-provider-add.md) — Add an identity provider for route protection
- [`miren auth provider list`](./auth-provider-list.md) — List identity providers
- [`miren auth provider remove`](./auth-provider-remove.md) — Remove an identity provider
- [`miren auth provider show`](./auth-provider-show.md) — Show an identity provider
## See also
- [`miren auth`](./auth.md)
---
## miren auth
Authentication commands
## Usage
```bash
miren auth [flags]
```
## Subcommands
- [`miren auth ci`](./auth-ci.md) — CI authentication binding management
- [`miren auth generate`](./auth-generate.md) — Generate authentication config file
- [`miren auth provider`](./auth-provider.md) — Identity provider management
---
## miren cluster add
Add a new cluster configuration
With `--format json`, the command prints one result document and nothing else on stdout; progress and warnings go to stderr. A failure is reported both as a document and as a non-zero exit status.
```json
{"ok": true, "cluster": {"name": "prod", "xid": "cluster-...", "organization": "Acme",
"address": "10.0.0.1:8443", "via_cloud": false,
"identity": "cloud", "active": true,
"config_file": "~/.config/miren/clientconfig.d/prod.yaml"}}
{"ok": false, "error": {"code": "cluster_not_found", "message": "no cluster named ..."}}
```
`name` is the local name, which is what every other command takes. `cloud_name` appears alongside it only when `--as` stored the cluster under a different name than it has in Miren Cloud, and `address` is absent for a cluster reached through cloud.
Messages are written for people and will be reworded. The code is the stable part:
| Code | Meaning |
|------|---------|
| `invalid_flags` | The flags given can't mean anything together. |
| `interactive_required` | Answering needs a person. Name a cluster with `--cluster`, or use `--force` to overwrite. |
| `no_identities` | Nobody is logged in. Run `miren login`. |
| `identity_error` | The identity named wasn't found, or one has to be named with `--identity`. |
| `cloud_request_failed` | Miren Cloud didn't answer. Worth retrying. |
| `cluster_not_found` | No cluster by that name, or none on the account. |
| `ambiguous_cluster` | The name exists in more than one organization. Add `--organization`. |
| `unknown_organization` | No organization by that name. |
| `cluster_unreachable` | The cluster exists and couldn't be reached. Worth retrying. |
| `cluster_exists` | That local name is taken. Use `--force`, or `--as` to pick another. |
| `config_error` | The local config couldn't be read or written. |
| `unknown` | A failure with no code. Treat it as uninterpretable. |
## Usage
```bash
miren cluster add [flags]
```
## Flags
- `--address, -a` — Address/hostname of the cluster (optional - will use from selected cluster)
- `--as` — Local name to store the cluster under, when it should differ from its name in Miren Cloud
- `--cluster, -c` — Name of the cluster to add, looked up in Miren Cloud unless --address is given (optional - will list available)
- `--force, -f` — Overwrite existing cluster configuration
- `--format` — Output format (text, json) (default: `text`)
- `--identity, -i` — Name of the identity to use (optional - will use the only one if single)
- `--json` — Shorthand for --format json
- `--organization` — Organization the named cluster belongs to, for when the same name exists in more than one
- `--via-cloud` — Reach the cluster through Miren Cloud instead of dialing it, for a cluster this machine has no route to
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Add a cluster interactively:**
```bash
miren cluster add
```
**Add a cluster by name, without the picker:**
```bash
miren cluster add --cluster my-cluster
```
**Add a cluster by name under a different local name:**
```bash
miren cluster add --cluster my-cluster --as staging
```
**Add a cluster, reporting the result (and any failure) as JSON:**
```bash
miren cluster add --cluster my-cluster --format json
```
**Add a cluster with a specific address:**
```bash
miren cluster add --cluster my-cluster --address 10.0.0.1:8443
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster available
List the clusters Miren Cloud has for your account
## Usage
```bash
miren cluster available [flags]
```
## Flags
- `--check` — Ask cloud whether it can reach the clusters that advertise no address
- `--format` — Output format (text, json) (default: `text`)
- `--identity, -i` — Name of the identity to use (optional - will use the only one if single)
- `--json` — Shorthand for --format json
- `--organization` — Only list clusters in this organization
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List clusters you could add:**
```bash
miren cluster available
```
**List as JSON:**
```bash
miren cluster available --format json
```
**Ask cloud whether it can reach the clusters with no address:**
```bash
miren cluster available --check
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster current
Show the pinned cluster for this app
## Usage
```bash
miren cluster current [flags]
```
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show current cluster:**
```bash
miren cluster current
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster export-address
Export cluster address with TLS fingerprint for MIREN_CLUSTER
## Usage
```bash
miren cluster export-address [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Export active cluster:**
```bash
miren cluster export-address
```
**Export specific cluster:**
```bash
miren cluster export-address -C my-cluster
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster list
List all configured clusters
## Usage
```bash
miren cluster list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all clusters:**
```bash
miren cluster list
```
**List as JSON:**
```bash
miren cluster list --format json
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster remove
Remove a cluster from the configuration
## Usage
```bash
miren cluster remove [cluster] [flags]
```
## Arguments
- `cluster` — Name of the cluster to remove
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove a cluster:**
```bash
miren cluster remove my-cluster
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster restart
Restart the active cluster's server and wait for it to report ready
## Usage
```bash
miren cluster restart [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--yes, -y` — Skip the confirmation prompt
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Restart a named cluster:**
```bash
miren cluster restart -C production
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster switch
Switch to a different cluster
## Usage
```bash
miren cluster switch [cluster] [flags]
```
## Arguments
- `cluster` — Name of the cluster to switch to
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Switch to a cluster:**
```bash
miren cluster switch production
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster upgrade
Upgrade the active cluster's server and wait for it to report ready
## Usage
```bash
miren cluster upgrade [flags]
```
## Flags
- `--channel` — Channel to use: 'latest' (stable releases, default) or 'main' (bleeding edge)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--health-timeout` — Seconds to wait for the restarted server to report ready (default: `0`)
- `--no-auto-rollback` — Disable automatic rollback on failure
- `--release, -r` — Upgrade full release package (not just base)
- `--version, -V` — Specific version to upgrade to (e.g., v0.2.0)
- `--yes, -y` — Skip the confirmation prompt
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Upgrade a named cluster to the latest release:**
```bash
miren cluster upgrade -C production
```
**Upgrade to a specific version without prompting:**
```bash
miren cluster upgrade --version v0.16.0 --yes
```
## See also
- [`miren cluster`](./cluster.md)
---
## miren cluster
List configured clusters
## Usage
```bash
miren cluster [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List clusters:**
```bash
miren cluster
```
## Subcommands
- [`miren cluster add`](./cluster-add.md) — Add a new cluster configuration
- [`miren cluster available`](./cluster-available.md) — List the clusters Miren Cloud has for your account
- [`miren cluster current`](./cluster-current.md) — Show the pinned cluster for this app
- [`miren cluster export-address`](./cluster-export-address.md) — Export cluster address with TLS fingerprint for MIREN_CLUSTER
- [`miren cluster list`](./cluster-list.md) — List all configured clusters
- [`miren cluster remove`](./cluster-remove.md) — Remove a cluster from the configuration
- [`miren cluster restart`](./cluster-restart.md) — Restart the active cluster's server and wait for it to report ready
- [`miren cluster switch`](./cluster-switch.md) — Switch to a different cluster
- [`miren cluster upgrade`](./cluster-upgrade.md) — Upgrade the active cluster's server and wait for it to report ready
---
## miren config info
Show configuration file locations and format
## Usage
```bash
miren config info [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show config info:**
```bash
miren config info
```
## See also
- [`miren config`](./config.md)
---
## miren config load
Load config and merge it with your current config
## Usage
```bash
miren config load [flags]
```
## Flags
- `--config` — Path to the config file to update
- `--force, -f` — Force the update
- `--input, -i` — Path to the input config file to add
- `--set-active, -a` — Set the active cluster
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Load a config file:**
```bash
miren config load --input cluster-config.yaml
```
**Load and set as active cluster:**
```bash
miren config load --input cluster-config.yaml --set-active
```
## See also
- [`miren config`](./config.md)
---
## miren config
Configuration file management
## Usage
```bash
miren config [flags]
```
## Subcommands
- [`miren config info`](./config-info.md) — Show configuration file locations and format
- [`miren config load`](./config-load.md) — Load config and merge it with your current config
---
## miren debug advertise
Show which addresses the server would advertise and why
## Usage
```bash
miren debug advertise [flags]
```
## Flags
- `--additional-ip` — Simulate a server-configured AdditionalIP (repeatable)
- `--cloud-url` — Cloud URL to use for netcheck (default: https://api.miren.cloud)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--listen` — Simulate the server's listen address (default: 0.0.0.0:8443)
- `--skip-netcheck` — Skip the netcheck call and only report interface scan
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug`](./debug.md)
---
## miren debug bundle
Create a support bundle with system debug information
## Usage
```bash
miren debug bundle [flags]
```
## Flags
- `--docker-container, -d` — Docker container name to get logs from (default: `miren`)
- `--namespace` — containerd namespace (default: `miren`)
- `--output, -o` — Output file path (default: `miren-debug.tar.gz`)
- `--since, -s` — Include logs since this time (default: `1 day ago`)
- `--socket` — path to containerd socket
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug`](./debug.md)
---
## miren debug cloud-sync
Show runtime entity sync diagnostics
## Usage
```bash
miren debug cloud-sync [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug`](./debug.md)
---
## miren debug colors
Print some colors
## Usage
```bash
miren debug colors [flags]
```
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug`](./debug.md)
---
## miren debug connection
Test connectivity and authentication with a server
## Usage
```bash
miren debug connection [flags]
```
## Flags
- `--cluster, -c` — Cluster name from config to test
- `--identity, -i` — Identity name to use for authentication
- `--insecure` — Skip TLS certificate verification
- `--server, -s` — Server hostname or IP address to test directly
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug`](./debug.md)
---
## miren debug ctr nuke
Nuke a containerd namespace
## Usage
```bash
miren debug ctr nuke [flags]
```
## Flags
- `--containerd-socket` — path to containerd socket
- `--containers, -c` — nuke containers only
- `--namespace, -n` — namespace to nuke
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug ctr`](./debug-ctr.md)
---
## miren debug ctr
Run ctr with miren defaults
## Usage
```bash
miren debug ctr [args...] [flags]
```
## Flags
- `--namespace, -n` — containerd namespace (default: `miren`)
- `--socket` — path to containerd socket
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Subcommands
- [`miren debug ctr nuke`](./debug-ctr-nuke.md) — Nuke a containerd namespace
## See also
- [`miren debug`](./debug.md)
---
## miren debug deploy-events
Render a 'miren deploy --format jsonl' stream as readable output
A deploy run with `--format jsonl` writes one JSON object per line and nothing a person would want to read. This command turns that stream back into the output the deploy would have printed: the phase summaries, build steps, health verdict, routes, and final status. Point it at a saved file, or pipe a live deploy through it.
Build step log lines are hidden unless `--build-logs` is set; `--timestamps` prefixes every line with the time since the first event, which is the quickest way to see where a slow deploy spent its time. Lines that are not JSON (stderr text mixed into the same file, say) are shown as they are.
## Usage
```bash
miren debug deploy-events [file] [flags]
```
## Arguments
- `file` — JSONL file written by 'miren deploy --format jsonl' (default: stdin)
## Flags
- `--build-logs` — Show the log lines of every build step, not just the steps
- `--timestamps, -t` — Prefix each line with the time elapsed since the first event
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Read a captured deploy:**
```bash
miren debug deploy-events deploy.jsonl
```
**With elapsed times and build output:**
```bash
miren debug deploy-events -t --build-logs deploy.jsonl
```
**Watch a deploy live:**
```bash
miren deploy --format jsonl | tee deploy.jsonl | miren debug deploy-events
```
## See also
- [`miren debug`](./debug.md)
---
## miren debug disk create
Create a disk entity for testing
Disks are normally created automatically when referenced from an app.toml. This command exists to test manual disk creation only.
## Usage
```bash
miren debug disk create [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--created-by, -c` — Creator ID for the disk
- `--filesystem, -f` — Filesystem type (ext4, xfs, btrfs) (default: `ext4`)
- `--name, -n` — Name for the disk
- `--remote-only, -r` — Store disk only in remote storage (no local replica)
- `--size, -s` — Size of disk in GB (default: `10`)
- `--volume-id, -V` — Attach to existing volume instead of creating new one
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk delete
Delete a disk entity
:::warning
This is a dangerous command. Only disks without bound leases should be deleted. This marks the disk for deletion. The disk controller will clean up the underlying storage. Ensure no apps are using the disk before deletion.
:::
## Usage
```bash
miren debug disk delete [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--id, -i` — Disk ID to delete
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk lease-delete
Delete a disk lease entity
## Usage
```bash
miren debug disk lease-delete [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force, -f` — Force deletion without releasing
- `--id, -i` — Lease ID to delete
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk lease-list
List all disk lease entities
## Usage
```bash
miren debug disk lease-list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--disk, -d` — Filter by disk ID
- `--sandbox, -s` — Filter by sandbox ID
- `--status` — Filter by status (pending, bound, released, failed)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk lease-release
Release a disk lease
## Usage
```bash
miren debug disk lease-release [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--id, -i` — Lease ID to release
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk lease-status
Show detailed status of a disk lease
## Usage
```bash
miren debug disk lease-status [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--id, -i` — Lease ID to check
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk lease
Create a disk lease for testing
## Usage
```bash
miren debug disk lease [flags]
```
## Flags
- `--app, -a` — App ID for the lease
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--disk, -d` — Disk ID to lease
- `--hours, -H` — Lease duration in hours (default: `2`)
- `--path, -p` — Mount path in sandbox (default: `/data`)
- `--readonly, -r` — Mount as read-only
- `--sandbox, -s` — Sandbox ID for the lease
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk list
List all disk entities
## Usage
```bash
miren debug disk list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk mounts
List all mounted disks from /proc/mounts
## Usage
```bash
miren debug disk mounts [flags]
```
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk status
Show status of a disk entity
## Usage
```bash
miren debug disk status [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--id, -i` — Disk ID to check
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug disk`](./debug-disk.md)
---
## miren debug disk
Disk entity debug commands
Commands for managing Miren disks. These commands are primarily used for troubleshooting and advanced operations.
## Disk Status Values
| Status | Description |
|--------|-------------|
| `provisioning` | Disk is being created and storage is being allocated |
| `provisioned` | Disk is ready and available for lease |
| `attached` | Disk has an active lease and is mounted |
| `detached` | Disk was previously attached but lease was released |
| `deleting` | Disk is marked for deletion |
| `error` | Disk encountered an error during provisioning |
## Lease Status Values
| Status | Description |
|--------|-------------|
| `pending` | Lease is waiting to acquire the disk |
| `bound` | Lease is active and disk is mounted |
| `released` | Lease has been released, cleanup pending |
| `failed` | Lease failed to acquire or mount the disk |
## Troubleshooting
**Disk stuck in "provisioning":**
Check server logs for storage backend errors:
```bash
miren debug disk status -i
```
**Lease stuck in "pending":**
The disk may not be provisioned yet, or another lease may have the disk:
```bash
miren debug disk lease-list -d
```
**App won't start due to disk timeout:**
Increase the `lease_timeout` in your app configuration, or check if another app has an active lease on the disk.
## Usage
```bash
miren debug disk [flags]
```
## Subcommands
- [`miren debug disk create`](./debug-disk-create.md) — Create a disk entity for testing
- [`miren debug disk delete`](./debug-disk-delete.md) — Delete a disk entity
- [`miren debug disk lease`](./debug-disk-lease.md) — Create a disk lease for testing
- [`miren debug disk lease-delete`](./debug-disk-lease-delete.md) — Delete a disk lease entity
- [`miren debug disk lease-list`](./debug-disk-lease-list.md) — List all disk lease entities
- [`miren debug disk lease-release`](./debug-disk-lease-release.md) — Release a disk lease
- [`miren debug disk lease-status`](./debug-disk-lease-status.md) — Show detailed status of a disk lease
- [`miren debug disk list`](./debug-disk-list.md) — List all disk entities
- [`miren debug disk mounts`](./debug-disk-mounts.md) — List all mounted disks from /proc/mounts
- [`miren debug disk status`](./debug-disk-status.md) — Show status of a disk entity
## See also
- [`miren debug`](./debug.md)
---
## miren debug entity create
Create a new entity
## Usage
```bash
miren debug entity create [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -d` — Dry run, do not actually create the entity
- `--id, -i` — ID of the entity (optional, auto-generated if not provided)
- `--path, -p` — Path to the entity file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity delete
Delete an entity
## Usage
```bash
miren debug entity delete [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--id, -i` — Entity ID
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity ensure
Ensure an entity exists
## Usage
```bash
miren debug entity ensure [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -d` — Dry run, do not actually ensure the entity
- `--id, -i` — ID of the entity (required)
- `--path, -p` — Path to the entity file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity get
Get an entity
## Usage
```bash
miren debug entity get [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--expand` — Show nested components in full
- `--format` — Output format (text, json) (default: `text`)
- `--id, -i` — Entity ID
- `--json` — Shorthand for --format json
- `--max-value-len` — Elide values longer than this, 0 for no limit (default: `512`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity list
List entities
## Usage
```bash
miren debug entity list [flags]
```
## Flags
- `--address` — Address to listen on (default: `localhost:8443`)
- `--after` — Resume from the cursor printed by a previous page
- `--attribute, -a` — Attribute to filter by
- `--cluster, -C` — Cluster name
- `--columns` — Comma-separated fields to use as table columns
- `--config` — Path to the config file
- `--detail, -l` — Show each entity in full instead of one line each
- `--expand` — Show nested components in full
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--kind, -k` — Kind of entity to filter by
- `--limit, -n` — Max entities to show, 0 for all (default: `50`)
- `--max-value-len` — Elide values longer than this, 0 for no limit (default: `120`)
- `--value, -V` — Value to filter by
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity patch
Patch an existing entity
## Usage
```bash
miren debug entity patch [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -d` — Dry run, do not actually patch the entity
- `--id, -i` — ID of the entity (required)
- `--path, -p` — Path to the entity file with updates
- `--revision, -r` — Expected revision for optimistic concurrency (default: `0`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity put
Put an entity
:::warning
This is an advanced command. Use the higher-level commands like `miren deploy` instead when possible.
:::
## Usage
```bash
miren debug entity put [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -d` — Dry run, do not actually put the entity
- `--id, -i` — ID of the entity
- `--path, -p` — Path to the entity
- `--update, -u` — Update the entity if it exists
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity replace
Replace an existing entity
## Usage
```bash
miren debug entity replace [flags]
```
## Flags
- `--address, -a` — Address to listen on (default: `localhost:8443`)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -d` — Dry run, do not actually replace the entity
- `--id, -i` — ID of the entity (required)
- `--path, -p` — Path to the entity file
- `--revision, -r` — Expected revision for optimistic concurrency (default: `0`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug entity`](./debug-entity.md)
---
## miren debug entity
Entity store debug commands
Entities are the low-level objects stored in Miren's entity system. Most users won't need to use these commands directly. They're primarily useful for debugging and advanced use cases.
## What are Entities?
Entities are flexible metadata objects stored in Miren's etcd-backed entity store. Everything in Miren is an entity:
- **Apps** - Application definitions
- **Sandboxes** - Running containers
- **Versions** - Immutable app configurations
- **Clusters** - Cluster registrations
- **Users** - User accounts
## Usage
```bash
miren debug entity [flags]
```
## Subcommands
- [`miren debug entity create`](./debug-entity-create.md) — Create a new entity
- [`miren debug entity delete`](./debug-entity-delete.md) — Delete an entity
- [`miren debug entity ensure`](./debug-entity-ensure.md) — Ensure an entity exists
- [`miren debug entity get`](./debug-entity-get.md) — Get an entity
- [`miren debug entity list`](./debug-entity-list.md) — List entities
- [`miren debug entity patch`](./debug-entity-patch.md) — Patch an existing entity
- [`miren debug entity put`](./debug-entity-put.md) — Put an entity
- [`miren debug entity replace`](./debug-entity-replace.md) — Replace an existing entity
## See also
- [`miren debug`](./debug.md)
---
## miren debug etcdctl
Run etcdctl against Miren's embedded etcd
Place Miren's `-n`/`--namespace` and `--socket` flags before the etcdctl arguments. Once the etcdctl arguments begin, all remaining flags are passed through to etcdctl unchanged.
## Usage
```bash
miren debug etcdctl [args...] [flags]
```
## Flags
- `--namespace, -n` — containerd namespace (default: `miren`)
- `--socket` — path to containerd socket
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show endpoint status:**
```bash
miren debug etcdctl endpoint status --write-out=table
```
**List every key:**
```bash
miren debug etcdctl get / --prefix --keys-only
```
## See also
- [`miren debug`](./debug.md)
---
## miren debug netdb gc
Find and release orphaned IP leases
## Usage
```bash
miren debug netdb gc [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -n` — Show what would be released without making changes
- `--force, -f` — Skip confirmation prompt
- `--subnet, -s` — Only GC IPs in this subnet
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug netdb`](./debug-netdb.md)
---
## miren debug netdb list
List all IP leases from netdb
## Usage
```bash
miren debug netdb list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--released, -R` — Show only released IPs
- `--reserved, -r` — Show only reserved (in-use) IPs
- `--subnet, -s` — Filter by subnet CIDR
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug netdb`](./debug-netdb.md)
---
## miren debug netdb release
Manually release IP leases
## Usage
```bash
miren debug netdb release [flags]
```
## Flags
- `--all, -a` — Release all reserved IPs (use with caution)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force, -f` — Skip confirmation prompt
- `--ip, -i` — Specific IP to release
- `--subnet, -s` — Release all reserved IPs in subnet
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug netdb`](./debug-netdb.md)
---
## miren debug netdb status
Show IP allocation status by subnet
## Usage
```bash
miren debug netdb status [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug netdb`](./debug-netdb.md)
---
## miren debug netdb
Network database debug commands
## Usage
```bash
miren debug netdb [flags]
```
## Subcommands
- [`miren debug netdb gc`](./debug-netdb-gc.md) — Find and release orphaned IP leases
- [`miren debug netdb list`](./debug-netdb-list.md) — List all IP leases from netdb
- [`miren debug netdb release`](./debug-netdb-release.md) — Manually release IP leases
- [`miren debug netdb status`](./debug-netdb-status.md) — Show IP allocation status by subnet
## See also
- [`miren debug`](./debug.md)
---
## miren debug rbac test
Test RBAC evaluation with fetched rules
## Usage
```bash
miren debug rbac test [flags]
```
## Flags
- `--action, -a` — Action to test
- `--dir, -d` — Registration directory (default: `/var/lib/miren/server`)
- `--group, -g` — Groups to test with
- `--resource, -r` — Resource to test
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug rbac`](./debug-rbac.md)
---
## miren debug rbac
Fetch and display RBAC rules from miren.cloud
## Usage
```bash
miren debug rbac [flags]
```
## Flags
- `--dir, -d` — Registration directory (default: `/var/lib/miren/server`)
- `--raw, -r` — Show raw JSON response
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Subcommands
- [`miren debug rbac test`](./debug-rbac-test.md) — Test RBAC evaluation with fetched rules
## See also
- [`miren debug`](./debug.md)
---
## miren debug reindex
Rebuild all entity indexes from scratch
## Usage
```bash
miren debug reindex [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dry-run, -d` — Show what would be done without making changes
- `--yes, -y` — Skip confirmation prompt
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug`](./debug.md)
---
## miren debug saga list
List saga executions
By default this lists only active sagas, meaning the ones that are pending, running, or undoing. Completed sagas accumulate and are rarely what you are looking for when something is wedged. Pass `--all` to include them, or `--status` to ask for one specific status.
The UPDATED column is the useful one for finding a stuck saga: the record is written after every action, so a saga that has been running for an hour without an update has stopped making progress.
```bash
miren debug saga list
miren debug saga list --status failed
miren debug saga list --definition provision_mysql_dedicated --all
miren debug saga list --format json
```
## Usage
```bash
miren debug saga list [flags]
```
## Flags
- `--all, -A` — Include completed and failed sagas
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--definition, -d` — Filter by saga definition name
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--status, -s` — Filter by status (pending, running, undoing, completed, failed)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug saga`](./debug-saga.md)
---
## miren debug saga show
Show a saga execution in detail
Shows one saga execution in full: its status, its initial inputs, and every action it ran with timing, undo state, and output.
:::note[Output details]
Action outputs are truncated by default so a long saga stays readable. Pass `--full` to print them whole. `--format json` always carries them in full, since a partial record is worse than a large one for anything parsing it.
Where a saga stopped is the last action listed. This shows the actions that ran, not the complete set the definition declares, since the saga definitions live in the server process and are not exposed over the API.
:::
```bash
miren debug saga show saga/sg-4TzP9hQ2mKdX8vNfR3wLbY
miren debug saga show saga/sg-4TzP9hQ2mKdX8vNfR3wLbY --full
miren debug saga show saga/sg-4TzP9hQ2mKdX8vNfR3wLbY --format json
```
## Usage
```bash
miren debug saga show [args...] [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--full` — Print complete action outputs instead of truncating them (JSON always includes them)
- `--id, -i` — Saga execution ID
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug saga`](./debug-saga.md)
---
## miren debug saga
Saga execution debug commands
Sagas are Miren's mechanism for multi-step operations that have to either finish or unwind cleanly, like provisioning an addon or building and deploying an app. Each saga execution records what it ran, in what order, and what each action returned, so a saga that wedges leaves behind a trail you can read.
These commands read that trail. The underlying records are also visible through `miren debug entity list -k saga`, but the interesting fields are stored as JSON blobs, so that view shows you base64 rather than what happened.
## Usage
```bash
miren debug saga [flags]
```
## Subcommands
- [`miren debug saga list`](./debug-saga-list.md) — List saga executions
- [`miren debug saga show`](./debug-saga-show.md) — Show a saga execution in detail
## See also
- [`miren debug`](./debug.md)
---
## miren debug test load
Loadtest a URL
## Usage
```bash
miren debug test load [flags]
```
## Arguments
- `url` — URL to load test
## Flags
- `--accept, -A` — Accept header to use
- `--auth, -a` — Basic auth header to use
- `--concurrency, -c` — Number of concurrent requests to make (default: `50`)
- `--content-type, -T` — Content-Type header to use (default: `text/html`)
- `--cpus` — Number of CPUs to use
- `--data, -d` — HTTP request body
- `--data-file, -D` — File to use as request body
- `--disable-compression` — Disable compression
- `--disable-keepalives` — Disable keep-alives
- `--disable-redirects` — Disable redirects
- `--duration, -z` — Duration of the test (default: `0s`)
- `--h2` — Use HTTP/2
- `--header, -H` — HTTP header to use
- `--host` — Host header to use
- `--method, -m` — HTTP method to use (default: `GET`)
- `--output, -o` — Output type, the only supported value is 'csv'
- `--proxy, -x` — Proxy URL to use
- `--requests, -n` — Number of requests to make (default: `200`)
- `--timeout, -t` — Timeout for each request in seconds (default: `20`)
- `--user-agent, -U` — User-Agent header to use
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren debug test`](./debug-test.md)
---
## miren debug test
Debug test commands
## Usage
```bash
miren debug test [flags]
```
## Subcommands
- [`miren debug test load`](./debug-test-load.md) — Loadtest a URL
## See also
- [`miren debug`](./debug.md)
---
## miren debug
Debug and troubleshooting commands
## Usage
```bash
miren debug [flags]
```
## Subcommands
- [`miren debug advertise`](./debug-advertise.md) — Show which addresses the server would advertise and why
- [`miren debug bundle`](./debug-bundle.md) — Create a support bundle with system debug information
- [`miren debug cloud-sync`](./debug-cloud-sync.md) — Show runtime entity sync diagnostics
- [`miren debug colors`](./debug-colors.md) — Print some colors
- [`miren debug connection`](./debug-connection.md) — Test connectivity and authentication with a server
- [`miren debug ctr`](./debug-ctr.md) — Run ctr with miren defaults
- [`miren debug deploy-events`](./debug-deploy-events.md) — Render a 'miren deploy --format jsonl' stream as readable output
- [`miren debug disk`](./debug-disk.md) — Disk entity debug commands
- [`miren debug entity`](./debug-entity.md) — Entity store debug commands
- [`miren debug etcdctl`](./debug-etcdctl.md) — Run etcdctl against Miren's embedded etcd
- [`miren debug netdb`](./debug-netdb.md) — Network database debug commands
- [`miren debug rbac`](./debug-rbac.md) — Fetch and display RBAC rules from miren.cloud
- [`miren debug reindex`](./debug-reindex.md) — Rebuild all entity indexes from scratch
- [`miren debug saga`](./debug-saga.md) — Saga execution debug commands
- [`miren debug test`](./debug-test.md) — Debug test commands
---
## miren deploy cancel
Cancel an in-progress deployment
## Usage
```bash
miren deploy cancel [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--deployment, -d` — ID of the deployment to cancel
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Cancel the current deployment:**
```bash
miren deploy cancel
```
**Cancel a specific deployment:**
```bash
miren deploy cancel -d dep_abc123
```
## See also
- [`miren deploy`](./deploy.md)
---
## miren deploy target add
Add a deployment target
## Usage
```bash
miren deploy target add [name] [clustername] [flags]
```
## Arguments
- `name` — Name for the deployment target (prompts when omitted)
- `clustername` — Configured cluster name (prompts when omitted)
## Flags
- `--default` — Make this the default deployment target
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Select a configured cluster interactively:**
```bash
miren deploy target add staging
```
**Add a target non-interactively:**
```bash
miren deploy target add prod my-prod-cluster
```
**Add the default target:**
```bash
miren deploy target add staging my-staging-cluster --default
```
## See also
- [`miren deploy target`](./deploy-target.md)
---
## miren deploy target list
List deployment targets
## Usage
```bash
miren deploy target list [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren deploy target`](./deploy-target.md)
---
## miren deploy target remove
Remove a deployment target
## Usage
```bash
miren deploy target remove [flags]
```
## Arguments
- `name` — Deployment target name
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove a target:**
```bash
miren deploy target remove staging
```
## See also
- [`miren deploy target`](./deploy-target.md)
---
## miren deploy target set-default
Set the default deployment target
## Usage
```bash
miren deploy target set-default [flags]
```
## Arguments
- `name` — Deployment target name
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Make production the default:**
```bash
miren deploy target set-default prod
```
## See also
- [`miren deploy target`](./deploy-target.md)
---
## miren deploy target
List deployment targets
## Usage
```bash
miren deploy target [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List deployment targets:**
```bash
miren deploy target
```
## Subcommands
- [`miren deploy target add`](./deploy-target-add.md) — Add a deployment target
- [`miren deploy target list`](./deploy-target-list.md) — List deployment targets
- [`miren deploy target remove`](./deploy-target-remove.md) — Remove a deployment target
- [`miren deploy target set-default`](./deploy-target-set-default.md) — Set the default deployment target
## See also
- [`miren deploy`](./deploy.md)
---
## miren deploy
Deploy an application
Deploy uploads your project files and configuration, selects the app's primary image, and activates the resulting version. A Dockerfile selected by `[build].dockerfile` or discovered as `Dockerfile.miren` is built first. Without one, a configured web image is resolved directly; otherwise Miren builds an image from automatically detected source. When source needs rebuilding, this is the command that does it.
To activate an existing version without selecting or building another image, pass `--version`:
```bash
miren deploy --version myapp-vCVkjR6u7744AsMebwMjGU
```
This reuses the existing image and rolls it out immediately. It is useful for rolling forward to a known-good version without waiting for an image to resolve or build. Find version IDs with `miren app history`.
## Scripting and CI
When stdout is not a terminal (a CI job, a pipe, a file), deploy prints plain text with no cursor-control escape codes, condenses the build to one summary line, and always ends with an explicit verdict and the full version ID on its own line:
```
✓ Deploy successful
Version: myapp-vCVkjR6u7744AsMebwMjGU
```
Use `--format json` to get the result as a single JSON document on stdout (`status`, `app_version`, `deploy_id`, `urls`); progress text moves to stderr, no prompts are shown, and the document is still written when the deploy fails, with `status` set to `failed` and an `error` field. `status` uses the deployment record's own vocabulary (`succeeded`, `failed`, `cancelled`), the same values `miren app history --format json` reports for the same `deploy_id`. Add `--quiet` to drop upload and build progress and keep only the phase summaries and the result.
Use `--format jsonl` to follow the deploy as it happens. Nothing unstructured is printed at all: stdout is one JSON object per line, each with an `event` name and a `time`, and stderr stays empty. Events are `start`, `message`, `upload`, `upload_complete`, `build_step` (with `status` started, done, cached, or error), `build_log`, `build_error`, `build_complete`, `deployment` (phase changes, using the deployment record's phase names), `warning`, `health` (an `outcome` of waiting, healthy, scaled_to_zero, task_only, crashed, or timeout, an `ok` flag, and the app's health classification with its instance counts), `port_warning`, `app_log` (recent crash output on a failed rollout), `log` (the CLI's own log records, which would otherwise go to stderr), and finally `result`, which carries the same fields as the `--format json` document.
:::note[Config changes deploy on their own]
Changing environment variables (`miren env set` / `miren env delete`) or addons (`miren addon create` / `miren addon destroy`) already creates and rolls out a new version. You only need `miren deploy` when your code or `app.toml` has changed.
:::
## Usage
```bash
miren deploy [flags]
```
## Flags
- `--analyze` — Analyze the app without building (show detected stack, services, etc.)
- `--env, -e` — Set environment variable (KEY=VALUE, KEY=@file, or KEY to prompt)
- `--ephemeral` — Deploy as ephemeral preview with this label (e.g. feat-login)
- `--explain, -x` — Explain the build process
- `--explain-format` — Explain format (default: `auto`) (choices: `auto`, `plain`, `tty`, `rawjson`, `quiet`)
- `--force, -f` — Skip confirmation prompt
- `--format` — Output format (text, json, jsonl) (default: `text`) (choices: `text`, `json`, `jsonl`)
- `--json` — Shorthand for --format json
- `--quiet, -q` — Suppress upload and build progress; print only phase summaries and the result
- `--sensitive, -s` — Set sensitive environment variable (masked in output)
- `--summary-json` — Write a JSON summary of the deploy result (deploy id, version, and route URLs) to this path
- `--target` — Deployment target from .miren/deploy.toml
- `--ttl` — TTL for ephemeral version (e.g. 48h) (default: `24h`)
- `--version, -V` — Deploy an existing version (reuse its resolved image; skip image selection and build)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Basic:**
```bash
miren deploy
```
**Analyze:**
```bash
Before deploying, the system can tell you how it's going
to treat your application by running:
miren deploy --analyze
```
**Set environment variables during deploy:**
```bash
miren deploy -e DATABASE_URL=postgres://localhost/mydb
```
**Deploy an existing version:**
```bash
miren deploy --version v3
```
**Deploy from a script or CI:**
```bash
Progress goes to stderr; stdout carries one JSON document
with the status, version, and URLs:
miren deploy --format json | jq -r .app_version
```
**Follow a deploy as a stream of events:**
```bash
One JSON object per line on stdout, nothing on stderr:
miren deploy --format jsonl | jq -c 'select(.event == "build_step")'
```
## Subcommands
- [`miren deploy cancel`](./deploy-cancel.md) — Cancel an in-progress deployment
- [`miren deploy target`](./deploy-target.md) — List deployment targets
---
## miren disk backup
Backup a disk to a snapshot file
## Usage
```bash
miren disk backup [flags]
```
## Flags
- `--cloud` — Also upload the snapshot to miren.cloud as a restore point
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--data-path` — Path to miren data directory (default: `/var/lib/miren`)
- `--name, -n` — Disk name to backup
- `--output, -o` — Output snapshot path (default: DISK-YYYYMMDD-HHMMSS.miren.zst)
- `--pin` — Name the uploaded restore point, pinning it against cleanup
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren disk`](./disk.md)
---
## miren disk list-deleted
List deleted disks available for recovery
## Usage
```bash
miren disk list-deleted [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--data-path` — Path to miren data directory (default: `/var/lib/miren`)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren disk`](./disk.md)
---
## miren disk restore
Restore a disk from a snapshot file
## Usage
```bash
miren disk restore [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--data-path` — Path to miren data directory (default: `/var/lib/miren`)
- `--force, -f` — Overwrite existing disk image without confirmation
- `--name, -n` — Disk name to restore to (default: original name from snapshot)
- `--snapshot, -s` — Path to snapshot file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren disk`](./disk.md)
---
## miren disk undelete
Restore a recently deleted disk
## Usage
```bash
miren disk undelete [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--data-path` — Path to miren data directory (default: `/var/lib/miren`)
- `--name, -n` — Disk name to undelete
- `--volume-id, -V` — Volume ID to restore (when multiple deleted disks share a name)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren disk`](./disk.md)
---
## miren disk
Disk backup and recovery
## Usage
```bash
miren disk [flags]
```
## Subcommands
- [`miren disk backup`](./disk-backup.md) — Backup a disk to a snapshot file
- [`miren disk list-deleted`](./disk-list-deleted.md) — List deleted disks available for recovery
- [`miren disk restore`](./disk-restore.md) — Restore a disk from a snapshot file
- [`miren disk undelete`](./disk-undelete.md) — Restore a recently deleted disk
---
## miren doctor
Diagnose miren environment and connectivity
## Usage
```bash
miren doctor [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Run all diagnostics:**
```bash
miren doctor
```
---
## miren download release
Download and extract miren release
## Usage
```bash
miren download release [flags]
```
## Flags
- `--branch, -b` — Branch name to download
- `--force, -f` — Force download even if release directory exists
- `--global, -g` — Install globally to /var/lib/miren/release
- `--output, -o` — Custom output directory
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Download the latest release:**
```bash
miren download release
```
## See also
- [`miren download`](./download.md)
---
## miren download
Download management commands
## Usage
```bash
miren download [flags]
```
## Subcommands
- [`miren download release`](./download-release.md) — Download and extract miren release
---
## miren env delete
Delete environment variables
Deleting an environment variable creates a new app version and rolls it out automatically — you do not need to run `miren deploy` or `miren app restart` afterward. The new version reuses your existing container image (no rebuild), and the command waits for it to become healthy before returning.
:::warning[Deploy app.toml changes first]
`miren env delete` builds the new version by copying the *current server-side* spec, not your local `app.toml`. If you have pending `app.toml` changes, deploy them first, then delete the stale variable — otherwise the delete rolls out the server-side spec and your local edits won't be included. Variables declared in `app.toml` drop automatically when you remove them from the file and redeploy.
:::
## Usage
```bash
miren env delete [args...] [flags]
```
## Flags
- `--force, -f` — Skip confirmation prompt
- `--service, -S` — Delete env var from specific service only (if not specified, deletes global env var)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Delete a variable:**
```bash
miren env delete DATABASE_URL
```
**Delete without confirmation:**
```bash
miren env delete DATABASE_URL --force
```
**Delete a service-specific variable:**
```bash
miren env delete WORKERS --service worker
```
## See also
- [`miren env`](./env.md)
---
## miren env get
Get an environment variable value
## Usage
```bash
miren env get [flags]
```
## Arguments
- `key` — Environment variable key to get
## Flags
- `--service, -S` — Get env var for specific service (if not specified, gets global env var)
- `--unmask, -u` — Show actual value of sensitive variables instead of masking them
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Get a variable value:**
```bash
miren env get DATABASE_URL
```
**Reveal a sensitive variable:**
```bash
miren env get SECRET_KEY --unmask
```
## See also
- [`miren env`](./env.md)
---
## miren env list
List all environment variables
## Usage
```bash
miren env list [flags]
```
## Flags
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all variables:**
```bash
miren env list
```
**List as JSON:**
```bash
miren env list --format json
```
## See also
- [`miren env`](./env.md)
---
## miren env set
Set environment variables for an application
Setting an environment variable creates a new app version and rolls it out automatically — you do not need to run `miren deploy` or `miren app restart` afterward. The new version reuses your existing container image (no rebuild); Miren boots new sandboxes with the updated environment and drains the old ones. The command waits for the new version to become healthy before returning.
Use `-e` for plain values and `-s` for sensitive values (masked in output and logs). Bare `KEY=VALUE` arguments without a flag are treated as plain values, the same as `-e`. Values may contain commas. Note that `-s` affects display only — the value itself is stored in the clear. For a credential that should be encrypted at rest, store it with `miren secret set` instead. Pass `--service` to scope the change to a single service instead of all services.
:::note[No restart needed]
Environment variable changes take effect on their own. Running `miren app restart` afterward only triggers a redundant second rollout.
:::
## Usage
```bash
miren env set [args...] [flags]
```
## Flags
- `--backend, -b` — Source the value from a secret backend instead of setting it literally (default: cluster, with --ref)
- `--env, -e` — Set environment variables (use KEY to prompt, KEY=VALUE to set directly, KEY=@file to read from file)
- `--ref` — Backend-relative reference to the secret, e.g. payments/stripe-key
- `--sensitive, -s` — Set sensitive environment variables (use KEY to prompt with masking, KEY=VALUE to set directly, KEY=@file to read from file)
- `--service, -S` — Set env var for specific service only (if not specified, sets for all services)
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Set an environment variable:**
```bash
miren env set -e DATABASE_URL=postgres://localhost/mydb
```
**Set several variables without flags:**
```bash
miren env set LOG_LEVEL=debug ALLOWED_HOSTS=a.example.com,b.example.com
```
**Set a sensitive variable (prompted with masking):**
```bash
miren env set -s SECRET_KEY
```
**Set a variable from a file:**
```bash
miren env set -e CONFIG=@config.json
```
**Set a variable for a specific service:**
```bash
miren env set -e WORKERS=4 --service worker
```
## See also
- [`miren env`](./env.md)
---
## miren env
Environment variable management commands
## Usage
```bash
miren env [flags]
```
## Subcommands
- [`miren env delete`](./env-delete.md) — Delete environment variables
- [`miren env get`](./env-get.md) — Get an environment variable value
- [`miren env list`](./env-list.md) — List all environment variables
- [`miren env set`](./env-set.md) — Set environment variables for an application
---
## miren help
Show help for one or more commands
## Usage
```bash
miren help [args...] [flags]
```
## Flags
- `--commands` — List all commands with their synopsis
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all commands:**
```bash
miren help --commands
```
**List all commands as JSON:**
```bash
miren help --commands --format json
```
**Show help for multiple commands:**
```bash
miren help app.list version sandbox.stop
```
---
## miren init
Initialize a new application
## Usage
```bash
miren init [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dir, -d` — Application directory (defaults to current directory)
- `--name, -n` — Application name (defaults to directory name)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Initialize in current directory:**
```bash
miren init
```
**Initialize with a specific name:**
```bash
miren init --name myapp
```
---
## miren login
Authenticate with miren.cloud
## Usage
```bash
miren login [flags]
```
## Flags
- `--force, -f` — Overwrite existing identity without prompting
- `--identity, -i` — Name for this identity in config (default: `cloud`)
- `--key-name, -k` — Name for the authentication key (default: `miren-cli`)
- `--no-save` — Don't save credentials to config file
- `--persistent-key` — Register a persistent key instead of the default renewable token login
- `--url, -u` — Cloud URL (default: `https://miren.cloud`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Login:**
```bash
miren login
```
**Login to a specific cloud instance:**
```bash
miren login --url https://cloud.example.com
```
---
## miren logout
Remove local authentication credentials
## Usage
```bash
miren logout [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--identity, -i` — Name of the identity to remove
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Logout:**
```bash
miren logout
```
---
## miren logs app
View application logs
## Subcommands
`miren logs` has subcommands for different log sources:
```bash
miren logs app # Application logs (default)
miren logs sandbox # Sandbox logs
miren logs build # Build logs
miren logs system # System/server logs
```
Running `miren logs` without a subcommand shows app logs (backward compatible).
## Time Range
By default, logs show the last 100 lines. Use `--since` and `--until` to bound the window. Each accepts an RFC3339 timestamp, a friendlier `2006-01-02 15:04` style time (interpreted in your local timezone), or a duration that's read as "ago":
```bash
# Last 5 minutes (a duration is read as "ago")
miren logs --since 5m
# A bounded historical window, e.g. chasing an incident
miren logs --since "2026-06-25 14:00" --until "2026-06-25 14:30"
# From an absolute start up to now
miren logs --since 2026-06-25T14:00:00Z
# Everything up to a point in the past (start of retention through --until)
miren logs --until "2026-06-25 14:30"
```
`--since` and `--until` compose with `--grep` and `--service`. `--until` can't be combined with `--follow` (a live tail has no end). The older `--last` flag still works and is equivalent to `--since` with a duration:
```bash
# These two are equivalent
miren logs --last 1h
miren logs --since 1h
```
## Following Logs
Use `--follow` (or `-f`) to stream logs in real-time:
```bash
# Follow logs as they arrive
miren logs -f
# Follow logs for a specific app
miren logs app -a myapp -f
```
## Filtering by Service
Use `--service` to filter logs by service name (app logs only):
```bash
# Show only logs from the web service
miren logs app --service web
# Show worker service logs containing "error"
miren logs app --service worker -g error
```
## Filtering Logs
Use the `--grep` (or `-g`) flag to filter log output. The filter supports multiple syntax options for flexible searching.
### Filter Syntax
| Syntax | Description | Example |
|--------|-------------|---------|
| `word` | Match logs containing "word" (case-insensitive) | `error` |
| `"phrase"` | Match logs containing exact phrase | `"connection failed"` |
| `'phrase'` | Match logs containing exact phrase (alternate) | `'connection failed'` |
| `/regex/` | Match logs matching regex pattern | `/err(or)?/` |
| `-term` | Exclude logs matching term | `-debug` |
| `term1 term2` | Match logs containing ALL terms (AND) | `error timeout` |
### Filter Details
- **Case-insensitive**: All word and phrase matches are case-insensitive
- **AND logic**: Multiple terms must all match for a log line to be included
- **Negation**: Prefix any term with `-` to exclude matching lines
- **Quotes**: Use double (`"`) or single (`'`) quotes for phrases with spaces
- **Regex**: Enclose patterns in forward slashes (`/pattern/`) for regex matching
## Log Output Format
Log entries are displayed with the following format:
```
S 2024-01-15 10:30:45: [source] Log message here
```
- **Stream prefix**: `S` (stdout), `E` (stderr), `ERR` (error), `U` (user-oob)
- **Timestamp**: Date and time when the log was generated
- **Source**: Optional source identifier (sandbox ID, truncated if long)
- **Message**: The actual log content
## Usage
```bash
miren logs app [flags]
```
## Flags
- `--follow, -f` — Follow log output (live tail)
- `--format` — Output format (text, json) (default: `text`)
- `--grep, -g` — Filter logs (e.g., 'error', '"exact phrase"', 'error -debug', '/regex/')
- `--json` — Shorthand for --format json
- `--last, -l` — Show logs from the last duration
- `--service` — Filter logs by service name (e.g., 'web', 'worker')
- `--since` — Show logs since a time (RFC3339, '2006-01-02 15:04', or a duration like '2h' ago)
- `--until` — Show logs until a time (RFC3339, '2006-01-02 15:04', or a duration like '30m' ago); not valid with --follow
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**View logs for the current app:**
```bash
miren logs app
```
**Follow logs for a specific app:**
```bash
miren logs app -a myapp -f
```
**Filter logs by service:**
```bash
miren logs app --service web -f
```
## See also
- [`miren logs`](./logs.md)
---
## miren logs build
View build logs
## Usage
```bash
miren logs build [flags]
```
## Arguments
- `version` — Build version (e.g., v3)
## Flags
- `--follow, -f` — Follow log output (live tail)
- `--format` — Output format (text, json) (default: `text`)
- `--grep, -g` — Filter logs (e.g., 'error', '"exact phrase"', 'error -debug', '/regex/')
- `--json` — Shorthand for --format json
- `--last, -l` — Show logs from the last duration
- `--since` — Show logs since a time (RFC3339, '2006-01-02 15:04', or a duration like '2h' ago)
- `--until` — Show logs until a time (RFC3339, '2006-01-02 15:04', or a duration like '30m' ago); not valid with --follow
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**View build logs for a version:**
```bash
miren logs build v3
```
**View build logs for a specific app:**
```bash
miren logs build v3 -a myapp
```
## See also
- [`miren logs`](./logs.md)
---
## miren logs run
View logs for a task run
## Usage
```bash
miren logs run [flags]
```
## Arguments
- `run` — Run ID
## Flags
- `--follow, -f` — Follow log output (live tail)
- `--format` — Output format (text, json) (default: `text`)
- `--grep, -g` — Filter logs (e.g., 'error', '"exact phrase"', 'error -debug', '/regex/')
- `--json` — Shorthand for --format json
- `--last, -l` — Show logs from the last duration
- `--since` — Show logs since a time (RFC3339, '2006-01-02 15:04', or a duration like '2h' ago)
- `--until` — Show logs until a time (RFC3339, '2006-01-02 15:04', or a duration like '30m' ago); not valid with --follow
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**View a run's output:**
```bash
miren logs run run/myapp-migrate-4kq2np
```
**Follow a running task:**
```bash
miren logs run run/myapp-reindex-8xh1dc -f
```
## See also
- [`miren logs`](./logs.md)
---
## miren logs sandbox
View sandbox logs
## Usage
```bash
miren logs sandbox [flags]
```
## Arguments
- `sandboxid` — Sandbox ID
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--follow, -f` — Follow log output (live tail)
- `--format` — Output format (text, json) (default: `text`)
- `--grep, -g` — Filter logs (e.g., 'error', '"exact phrase"', 'error -debug', '/regex/')
- `--json` — Shorthand for --format json
- `--last, -l` — Show logs from the last duration
- `--since` — Show logs since a time (RFC3339, '2006-01-02 15:04', or a duration like '2h' ago)
- `--until` — Show logs until a time (RFC3339, '2006-01-02 15:04', or a duration like '30m' ago); not valid with --follow
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**View logs for a sandbox:**
```bash
miren logs sandbox sb_abc123
```
**Follow sandbox logs:**
```bash
miren logs sandbox sb_abc123 -f
```
## See also
- [`miren logs`](./logs.md)
---
## miren logs system
View system logs
## Usage
```bash
miren logs system [component] [flags]
```
## Arguments
- `component` — System component to filter by (e.g., 'etcd', 'scheduler')
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--follow, -f` — Follow log output (live tail)
- `--format` — Output format (text, json) (default: `text`)
- `--grep, -g` — Filter logs (e.g., 'error', '"exact phrase"', 'error -debug', '/regex/')
- `--json` — Shorthand for --format json
- `--last, -l` — Show logs from the last duration
- `--since` — Show logs since a time (RFC3339, '2006-01-02 15:04', or a duration like '2h' ago)
- `--until` — Show logs until a time (RFC3339, '2006-01-02 15:04', or a duration like '30m' ago); not valid with --follow
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**View all system logs:**
```bash
miren logs system
```
**View logs for a specific component:**
```bash
miren logs system etcd
```
**Follow system logs:**
```bash
miren logs system -f
```
## See also
- [`miren logs`](./logs.md)
---
## miren logs
View logs (defaults to app logs)
## Subcommands
`miren logs` has subcommands for different log sources:
```bash
miren logs app # Application logs (default)
miren logs sandbox # Sandbox logs
miren logs build # Build logs
miren logs system # System/server logs
```
Running `miren logs` without a subcommand shows app logs (backward compatible).
## Time Range
By default, logs show the last 100 lines. Use `--since` and `--until` to bound the window. Each accepts an RFC3339 timestamp, a friendlier `2006-01-02 15:04` style time (interpreted in your local timezone), or a duration that's read as "ago":
```bash
# Last 5 minutes (a duration is read as "ago")
miren logs --since 5m
# A bounded historical window, e.g. chasing an incident
miren logs --since "2026-06-25 14:00" --until "2026-06-25 14:30"
# From an absolute start up to now
miren logs --since 2026-06-25T14:00:00Z
# Everything up to a point in the past (start of retention through --until)
miren logs --until "2026-06-25 14:30"
```
`--since` and `--until` compose with `--grep` and `--service`. `--until` can't be combined with `--follow` (a live tail has no end). The older `--last` flag still works and is equivalent to `--since` with a duration:
```bash
# These two are equivalent
miren logs --last 1h
miren logs --since 1h
```
## Following Logs
Use `--follow` (or `-f`) to stream logs in real-time:
```bash
# Follow logs as they arrive
miren logs -f
# Follow logs for a specific app
miren logs app -a myapp -f
```
## Filtering by Service
Use `--service` to filter logs by service name (app logs only):
```bash
# Show only logs from the web service
miren logs app --service web
# Show worker service logs containing "error"
miren logs app --service worker -g error
```
## Filtering Logs
Use the `--grep` (or `-g`) flag to filter log output. The filter supports multiple syntax options for flexible searching.
### Filter Syntax
| Syntax | Description | Example |
|--------|-------------|---------|
| `word` | Match logs containing "word" (case-insensitive) | `error` |
| `"phrase"` | Match logs containing exact phrase | `"connection failed"` |
| `'phrase'` | Match logs containing exact phrase (alternate) | `'connection failed'` |
| `/regex/` | Match logs matching regex pattern | `/err(or)?/` |
| `-term` | Exclude logs matching term | `-debug` |
| `term1 term2` | Match logs containing ALL terms (AND) | `error timeout` |
### Filter Details
- **Case-insensitive**: All word and phrase matches are case-insensitive
- **AND logic**: Multiple terms must all match for a log line to be included
- **Negation**: Prefix any term with `-` to exclude matching lines
- **Quotes**: Use double (`"`) or single (`'`) quotes for phrases with spaces
- **Regex**: Enclose patterns in forward slashes (`/pattern/`) for regex matching
## Log Output Format
Log entries are displayed with the following format:
```
S 2024-01-15 10:30:45: [source] Log message here
```
- **Stream prefix**: `S` (stdout), `E` (stderr), `ERR` (error), `U` (user-oob)
- **Timestamp**: Date and time when the log was generated
- **Source**: Optional source identifier (sandbox ID, truncated if long)
- **Message**: The actual log content
## Usage
```bash
miren logs [flags]
```
## Flags
- `--follow, -f` — Follow log output (live tail)
- `--format` — Output format (text, json) (default: `text`)
- `--grep, -g` — Filter logs (e.g., 'error', '"exact phrase"', 'error -debug', '/regex/')
- `--json` — Shorthand for --format json
- `--last, -l` — Show logs from the last duration
- `--service` — Filter logs by service name (e.g., 'web', 'worker')
- `--since` — Show logs since a time (RFC3339, '2006-01-02 15:04', or a duration like '2h' ago)
- `--until` — Show logs until a time (RFC3339, '2006-01-02 15:04', or a duration like '30m' ago); not valid with --follow
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**View logs for the current app:**
```bash
miren logs
```
**Follow logs in real time:**
```bash
miren logs -f
```
**Show logs from the last 5 minutes, filtered for errors:**
```bash
miren logs --last 5m -g error
```
## Subcommands
- [`miren logs app`](./logs-app.md) — View application logs
- [`miren logs build`](./logs-build.md) — View build logs
- [`miren logs run`](./logs-run.md) — View logs for a task run
- [`miren logs sandbox`](./logs-sandbox.md) — View sandbox logs
- [`miren logs system`](./logs-system.md) — View system logs
---
## miren rollback
Roll back to a previous version
Rollback re-activates a previous version by reusing its already-resolved image. No image selection or build happens. It presents a picker of recent successful deployments and rolls out the one you choose immediately. The currently active version is excluded since rolling back to it would be a no-op.
Rollback creates a new deployment record; it does not erase history.
## Usage
```bash
miren rollback [flags]
```
## Config Options
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## App Options
- `--app, -a` — Application name
- `--dir, -d` — Directory to run from (default: `.`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rollback the app in the current directory:**
```bash
miren rollback
```
**Rollback a specific app:**
```bash
miren rollback -a myapp
```
---
## miren route down
Put an HTTP route into maintenance
Puts a route into maintenance. Visitors get HTTP 503 and a holding page instead of reaching the app; nothing else changes.
Maintenance is a routing decision and only a routing decision. The app's sandboxes keep running, so `miren app run` and one-shot migrations still work during the window — which is the whole point of taking traffic off the route first. Other hostnames pointing at the same app keep serving, and internal service-to-service calls are unaffected.
`--reason` is shown to visitors, so write it for them rather than for your team. `--back-at` accepts a clock time (`15:00`), a duration (`30m`), or an RFC 3339 timestamp; it drives both the holding page copy and the `Retry-After` header that crawlers and uptime monitors read.
:::note[Ephemeral preview URLs are covered]
Preview subdomains resolve through their base route, so taking `app.example.com` down also holds `feat-x.app.example.com`. Previews share the app's database, so a migration window that left them serving would not be a window at all.
:::
## Usage
```bash
miren route down [host] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com); omit and pass --default for the default route
## Flags
- `--back-at` — When the route is expected back: a clock time in your own timezone (15:00), a duration (30m), or an RFC 3339 timestamp
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Take the default route down (instead of a hostname)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--reason` — Explanation shown to visitors on the holding page
- `--yes, -y` — Skip the confirmation prompt for --default
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Take a route down with an explanation:**
```bash
miren route down example.com --reason "Upgrading the database"
```
**Take a route down with an expected return time:**
```bash
miren route down example.com --reason "DB migration" --back-at 15:00
```
**Take the default route down:**
```bash
miren route down --default --reason "Cluster upgrade"
```
## See also
- [`miren route`](./route.md)
---
## miren route list
List all HTTP routes
## Usage
```bash
miren route list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all routes:**
```bash
miren route list
```
**List as JSON:**
```bash
miren route list --format json
```
## See also
- [`miren route`](./route.md)
---
## miren route protect
Protect an HTTP route with an identity provider
## Usage
```bash
miren route protect [host] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com); omit and pass --default for the default route
## Flags
- `--claim-header` — Claim to header mapping in format 'claim:header' (e.g., 'email:X-User-Email')
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Protect the default route (instead of a hostname)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--provider` — Name of the identity provider
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Protect a route with an OIDC provider:**
```bash
miren route protect example.com --provider my-google-oidc --claim-header email:X-User-Email
```
**Protect a route with a password:**
```bash
miren route protect example.com --provider my-pw
```
**Protect the default route:**
```bash
miren route protect --default --provider my-google-oidc
```
## See also
- [`miren route`](./route.md)
---
## miren route remove
Remove an HTTP route
## Usage
```bash
miren route remove [host] [flags]
```
## Arguments
- `host` — Hostname of the route to remove
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove a route:**
```bash
miren route remove example.com
```
## See also
- [`miren route`](./route.md)
---
## miren route set-default
Set an app as the default route
## Usage
```bash
miren route set-default [appname] [flags]
```
## Arguments
- `appname` — Application name to set as default route
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Set the default route:**
```bash
miren route set-default myapp
```
## See also
- [`miren route`](./route.md)
---
## miren route set
Create or update an HTTP route
## Usage
```bash
miren route set [appname] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com or *.example.com)
- `appname` — Application name to route to
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--service` — HTTP-capable app service to route to (default: `web`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Route a domain to an app:**
```bash
miren route set example.com myapp
```
## See also
- [`miren route`](./route.md)
---
## miren route show
Show details of an HTTP route
## Usage
```bash
miren route show [host] [flags]
```
## Arguments
- `host` — Hostname of the route to show; omit and pass --default for the default route
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Show the default route (instead of a hostname)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show route details:**
```bash
miren route show example.com
```
## See also
- [`miren route`](./route.md)
---
## miren route timeout
Override the ingress request timeout for an HTTP route
## Usage
```bash
miren route timeout [host] [timeout] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com); omit and pass --default for the default route
- `timeout` — Request timeout as a duration (e.g., 10m, 300s); omit to show the current value
## Flags
- `--clear` — Remove the override so the route uses the server default
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Apply to the default route (instead of a hostname)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Give a long-poll route a 10-minute timeout:**
```bash
miren route timeout example.com 10m
```
**Show the current timeout for a route:**
```bash
miren route timeout example.com
```
**Set a timeout on the default route:**
```bash
miren route timeout --default 5m
```
**Go back to the server default timeout:**
```bash
miren route timeout example.com --clear
```
## See also
- [`miren route`](./route.md)
---
## miren route tls-check
Ask an app before issuing certificates for names under its route
## Usage
```bash
miren route tls-check [host] [path] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., *.example.com)
- `path` — Path on the app to ask before issuing a certificate (e.g., /tls-check); omit to show the current value
## Flags
- `--clear` — Remove the check so names under the route only get certificates as live ephemeral deploys
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Let a wildcard app vouch for its own subdomains:**
```bash
miren route tls-check '*.example.com' /tls-check
```
**Show the current check for a route:**
```bash
miren route tls-check '*.example.com'
```
**Remove the check:**
```bash
miren route tls-check '*.example.com' --clear
```
## See also
- [`miren route`](./route.md)
---
## miren route unprotect
Remove identity-provider protection from an HTTP route
## Usage
```bash
miren route unprotect [host] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com); omit and pass --default for the default route
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Remove protection from the default route (instead of a hostname)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove protection from a route:**
```bash
miren route unprotect example.com
```
## See also
- [`miren route`](./route.md)
---
## miren route unset-default
Remove the default route
## Usage
```bash
miren route unset-default [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove the default route:**
```bash
miren route unset-default
```
## See also
- [`miren route`](./route.md)
---
## miren route up
Bring an HTTP route out of maintenance
Brings a route out of maintenance and back to serving normally.
This clears the whole maintenance record, including the reason and the operator who set it — a stale "Upgrading the database" hanging off a healthy route is a trap for whoever reads it next.
Running this on a route that is already serving succeeds and says so, rather than erroring.
## Usage
```bash
miren route up [host] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com); omit and pass --default for the default route
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Bring the default route back (instead of a hostname)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Bring a route back:**
```bash
miren route up example.com
```
**Bring the default route back:**
```bash
miren route up --default
```
## See also
- [`miren route`](./route.md)
---
## miren route waf
Manage WAF protection on an HTTP route
## Usage
```bash
miren route waf [host] [flags]
```
## Arguments
- `host` — Hostname for the route (e.g., example.com); omit and pass --default for the default route
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--default` — Apply to the default route (instead of a hostname)
- `--disable` — Disable WAF on the route
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--level` — OWASP CRS paranoia level (1-4) (default: `1`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Enable WAF on a route with default paranoia level:**
```bash
miren route waf example.com
```
**Enable WAF with a specific paranoia level:**
```bash
miren route waf example.com --level 2
```
**Enable WAF on the default route:**
```bash
miren route waf --default
```
**Disable WAF on a route:**
```bash
miren route waf example.com --disable
```
## See also
- [`miren route`](./route.md)
---
## miren route
List all HTTP routes
## Usage
```bash
miren route [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all routes:**
```bash
miren route
```
## Subcommands
- [`miren route down`](./route-down.md) — Put an HTTP route into maintenance
- [`miren route list`](./route-list.md) — List all HTTP routes
- [`miren route protect`](./route-protect.md) — Protect an HTTP route with an identity provider
- [`miren route remove`](./route-remove.md) — Remove an HTTP route
- [`miren route set`](./route-set.md) — Create or update an HTTP route
- [`miren route set-default`](./route-set-default.md) — Set an app as the default route
- [`miren route show`](./route-show.md) — Show details of an HTTP route
- [`miren route timeout`](./route-timeout.md) — Override the ingress request timeout for an HTTP route
- [`miren route tls-check`](./route-tls-check.md) — Ask an app before issuing certificates for names under its route
- [`miren route unprotect`](./route-unprotect.md) — Remove identity-provider protection from an HTTP route
- [`miren route unset-default`](./route-unset-default.md) — Remove the default route
- [`miren route up`](./route-up.md) — Bring an HTTP route out of maintenance
- [`miren route waf`](./route-waf.md) — Manage WAF protection on an HTTP route
---
## miren runner cordon
Mark a runner unschedulable without stopping its sandboxes
## Usage
```bash
miren runner cordon [flags]
```
## Arguments
- `node` — Runner to cordon (name, ID, or short ID)
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--reason` — Optional reason for cordoning the runner
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Cordon a runner:**
```bash
miren runner cordon my-runner
```
**Cordon with a reason:**
```bash
miren runner cordon my-runner --reason "cert rotation"
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner drain
Cordon a runner and evict its sandboxes onto other nodes
## Usage
```bash
miren runner drain [flags]
```
## Arguments
- `node` — Runner to drain (name, ID, or short ID)
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--reason` — Optional reason for draining the runner
- `--timeout` — Max seconds to wait for the node to empty (0 uses the server default) (default: `0`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Drain a runner before maintenance:**
```bash
miren runner drain my-runner
```
**Drain with a timeout:**
```bash
miren runner drain my-runner --timeout 300
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner install
Install systemd service for miren runner
## Usage
```bash
miren runner install [flags]
```
## Flags
- `--branch, -b` — Branch to download
- `--config` — Path to runner config (default: `/var/lib/miren/runner/config.yaml`)
- `--coordinator, -c` — Override coordinator address from the token
- `--data-path` — Path to store runner data (default: `/var/lib/miren/runner`)
- `--force, -f` — Overwrite existing service file
- `--labels` — Runner labels (key=value)
- `--listen, -l` — Address this runner will listen on
- `--name` — Human-readable name for this runner (defaults to hostname)
- `--no-start` — Do not start the service after installation
- `--skip-system-check` — Skip minimum system requirements check
- `--token, -t` — Enrollment token from 'miren runner token create'
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Install interactively:**
```bash
miren runner install
```
**Install with token (for automation):**
```bash
miren runner install --token mren_...
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner join
Join this machine to a coordinator as a runner
## Usage
```bash
miren runner join [tokenarg] [flags]
```
## Arguments
- `tokenarg` — Join token from 'miren runner token create'
## Flags
- `--config` — Path to save runner config (default: `/var/lib/miren/runner/config.yaml`)
- `--coordinator, -c` — Override coordinator address from the token
- `--labels` — Additional labels for the runner (key=value)
- `--listen, -l` — Address this runner will listen on
- `--name` — Human-readable name for this runner (defaults to hostname)
- `--runner-id` — Specific runner ID to use (for reconnecting)
- `--token` — Enrollment token (or pass as positional arg / via stdin)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Join using a token:**
```bash
miren runner join mren_...
```
**Join with coordinator address override:**
```bash
miren runner join mren_... --coordinator 10.0.0.5:8443
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner list
List all registered runners
## Usage
```bash
miren runner list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List runners:**
```bash
miren runner list
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner operations abandon
Give up on an unfinished operation
## Usage
```bash
miren runner operations abandon [flags]
```
## Arguments
- `id` — Operation id
## Flags
- `--dir` — Operation directory (default: `/var/lib/miren/runner/lifecycle`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Abandon a stuck operation:**
```bash
sudo miren runner operations abandon 01J8X2M0QK4V6Z9W1N3RB5T7YC
```
## See also
- [`miren runner operations`](./runner-operations.md)
---
## miren runner operations list
List recorded restart and upgrade operations
## Usage
```bash
miren runner operations list [flags]
```
## Flags
- `--dir` — Operation directory (default: `/var/lib/miren/runner/lifecycle`)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List operations:**
```bash
miren runner operations list
```
## See also
- [`miren runner operations`](./runner-operations.md)
---
## miren runner operations run
Execute or resume an operation in the foreground (normally launched by miren upgrade)
## Usage
```bash
miren runner operations run [flags]
```
## Flags
- `--config` — Runner config, for reaching the runner to verify readiness (default: `/var/lib/miren/runner/config.yaml`)
- `--dir` — Operation directory (default: `/var/lib/miren/runner/lifecycle`)
- `--operation` — Operation id to execute or resume
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren runner operations`](./runner-operations.md)
---
## miren runner operations show
Show one restart or upgrade operation
## Usage
```bash
miren runner operations show [flags]
```
## Arguments
- `id` — Operation id
## Flags
- `--dir` — Operation directory (default: `/var/lib/miren/runner/lifecycle`)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show an operation:**
```bash
miren runner operations show 01J8X2M0QK4V6Z9W1N3RB5T7YC
```
## See also
- [`miren runner operations`](./runner-operations.md)
---
## miren runner operations
Durable runner restart and upgrade operations
## Usage
```bash
miren runner operations [flags]
```
## Subcommands
- [`miren runner operations abandon`](./runner-operations-abandon.md) — Give up on an unfinished operation
- [`miren runner operations list`](./runner-operations-list.md) — List recorded restart and upgrade operations
- [`miren runner operations run`](./runner-operations-run.md) — Execute or resume an operation in the foreground (normally launched by miren upgrade)
- [`miren runner operations show`](./runner-operations-show.md) — Show one restart or upgrade operation
## See also
- [`miren runner`](./runner.md)
---
## miren runner reissue
Rotate this runner's certificate in place (requires a still-valid cert), keeping its identity
## Usage
```bash
miren runner reissue [flags]
```
## Flags
- `--config` — Path to the runner config (default: `/var/lib/miren/runner/config.yaml`)
- `--coordinator, -c` — Override coordinator address (defaults to the runner config)
- `--listen, -l` — Address this runner listens on (covered by the new cert; auto-discovered if unset)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rotate this runner's certificate:**
```bash
miren runner reissue
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner remove
Remove a registered runner and clean up resources
## Usage
```bash
miren runner remove [flags]
```
## Arguments
- `node` — Runner to remove (name, ID, or short ID)
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force, -f` — Force removal even if the runner has active sandboxes
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Remove a runner by name:**
```bash
miren runner remove my-runner
```
**Force remove a runner with active sandboxes:**
```bash
miren runner remove my-runner --force
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner service-status
Show miren-runner systemd service status
## Usage
```bash
miren runner service-status [flags]
```
## Flags
- `--follow, -f` — Follow logs in real-time
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show service status:**
```bash
miren runner service-status
```
**Follow service logs:**
```bash
miren runner service-status --follow
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner start
Start this machine as a distributed runner
## Usage
```bash
miren runner start [flags]
```
## Flags
- `--config` — Path to runner config (default: `/var/lib/miren/runner/config.yaml`)
- `--containerd-socket` — Path to containerd socket
- `--data-path` — Path to store runner data (default: `/var/lib/miren/runner`)
- `--listen, -l` — Address this runner will listen on (overrides config)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Start the runner:**
```bash
miren runner start
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner status
Show runner health and configuration
## Usage
```bash
miren runner status [flags]
```
## Flags
- `--config` — Path to runner config (default: `/var/lib/miren/runner/config.yaml`)
- `--data-path` — Path to runner data (default: `/var/lib/miren/runner`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Check runner status:**
```bash
miren runner status
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner token create
Create a join token for a runner
## Usage
```bash
miren runner token create [flags]
```
## Flags
- `--addr, -a` — Override coordinator address baked into the token
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--expires, -e` — Hours until the invite expires (default: `1`)
- `--labels, -l` — Labels to apply to the runner (key=value format)
- `--name, -n` — Human-readable name for this invite
- `--reusable, -r` — Create a reusable invite (not consumed on use)
- `--ttl` — Time-to-live (e.g. 24h, 7d, 2w). Overrides --expires
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Create a one-time join token:**
```bash
miren runner token create
```
**Create a reusable token for automation:**
```bash
miren runner token create --reusable --name infra-terraform --ttl 0
```
**Create a token with a specific coordinator address:**
```bash
miren runner token create --addr 10.0.0.5:8443
```
## See also
- [`miren runner token`](./runner-token.md)
---
## miren runner token list
List all join tokens
## Usage
```bash
miren runner token list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List tokens:**
```bash
miren runner token list
```
## See also
- [`miren runner token`](./runner-token.md)
---
## miren runner token revoke
Revoke a join token
## Usage
```bash
miren runner token revoke [flags]
```
## Arguments
- `tokenid` — ID of the token to revoke
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Revoke a token:**
```bash
miren runner token revoke inv_abc123
```
## See also
- [`miren runner token`](./runner-token.md)
---
## miren runner token
Manage join tokens
## Usage
```bash
miren runner token [flags]
```
## Subcommands
- [`miren runner token create`](./runner-token-create.md) — Create a join token for a runner
- [`miren runner token list`](./runner-token-list.md) — List all join tokens
- [`miren runner token revoke`](./runner-token-revoke.md) — Revoke a join token
## See also
- [`miren runner`](./runner.md)
---
## miren runner uncordon
Make a cordoned runner eligible for scheduling again
## Usage
```bash
miren runner uncordon [flags]
```
## Arguments
- `node` — Runner to uncordon (name, ID, or short ID)
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Uncordon a runner:**
```bash
miren runner uncordon my-runner
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner uninstall
Remove systemd service for miren runner
## Usage
```bash
miren runner uninstall [flags]
```
## Flags
- `--data-path` — Path to runner data (default: `/var/lib/miren/runner`)
- `--remove-data` — Remove runner data directory
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Uninstall the runner service:**
```bash
miren runner uninstall
```
**Uninstall and remove all runner data:**
```bash
miren runner uninstall --remove-data
```
## See also
- [`miren runner`](./runner.md)
---
## miren runner upgrade rollback
Rollback runner to previous version
## Usage
```bash
miren runner upgrade rollback [flags]
```
## Flags
- `--skip-health` — Skip health check after rollback
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rollback to the previous version:**
```bash
miren runner upgrade rollback
```
## See also
- [`miren runner upgrade`](./runner-upgrade.md)
---
## miren runner upgrade
Upgrade miren runner to the latest or specified version
## Usage
```bash
miren runner upgrade [flags]
```
## Flags
- `--channel` — Channel to use instead of matching the coordinator: 'latest' (stable releases) or 'main' (bleeding edge)
- `--check, -c` — Check for available updates only
- `--force, -f` — Force upgrade even if already up to date
- `--health-timeout` — Seconds to wait for the restarted runner to report ready (default: `0`)
- `--no-auto-rollback` — Disable automatic rollback on failure
- `--skip-health` — Deprecated: readiness is always verified
- `--version, -V` — Specific version to upgrade to (e.g., v0.2.0); default is the coordinator's build
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Upgrade to the latest version:**
```bash
miren runner upgrade
```
**Check for available updates:**
```bash
miren runner upgrade --check
```
**Upgrade to a specific version:**
```bash
miren runner upgrade --version v0.2.0
```
## Subcommands
- [`miren runner upgrade rollback`](./runner-upgrade-rollback.md) — Rollback runner to previous version
## See also
- [`miren runner`](./runner.md)
---
## miren runner
Runner management commands
## Usage
```bash
miren runner [flags]
```
## Subcommands
- [`miren runner cordon`](./runner-cordon.md) — Mark a runner unschedulable without stopping its sandboxes
- [`miren runner drain`](./runner-drain.md) — Cordon a runner and evict its sandboxes onto other nodes
- [`miren runner install`](./runner-install.md) — Install systemd service for miren runner
- [`miren runner join`](./runner-join.md) — Join this machine to a coordinator as a runner
- [`miren runner list`](./runner-list.md) — List all registered runners
- [`miren runner operations`](./runner-operations.md) — Durable runner restart and upgrade operations
- [`miren runner reissue`](./runner-reissue.md) — Rotate this runner's certificate in place (requires a still-valid cert), keeping its identity
- [`miren runner remove`](./runner-remove.md) — Remove a registered runner and clean up resources
- [`miren runner service-status`](./runner-service-status.md) — Show miren-runner systemd service status
- [`miren runner start`](./runner-start.md) — Start this machine as a distributed runner
- [`miren runner status`](./runner-status.md) — Show runner health and configuration
- [`miren runner token`](./runner-token.md) — Manage join tokens
- [`miren runner uncordon`](./runner-uncordon.md) — Make a cordoned runner eligible for scheduling again
- [`miren runner uninstall`](./runner-uninstall.md) — Remove systemd service for miren runner
- [`miren runner upgrade`](./runner-upgrade.md) — Upgrade miren runner to the latest or specified version
---
## miren sandbox delete
Delete a dead sandbox
## Usage
```bash
miren sandbox delete [flags]
```
## Arguments
- `sandboxid` — ID of the sandbox to delete
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force, -f` — Force delete without confirmation
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Delete a sandbox:**
```bash
miren sandbox delete sb_abc123
```
**Force delete without confirmation:**
```bash
miren sandbox delete sb_abc123 --force
```
## See also
- [`miren sandbox`](./sandbox.md)
---
## miren sandbox exec
Open interactive shell in an existing sandbox
This command connects to an existing sandbox and runs a command inside it. Unlike `miren app run` which creates a new ephemeral sandbox, this connects to a sandbox that's already running (typically one serving production traffic).
## Letting miren pick the sandbox
Pass `--app` and you don't need a sandbox ID at all — miren picks one of that app's running sandboxes for you:
```bash
miren sandbox exec --app myapp
miren sandbox exec --app myapp -- ls -la /app
```
The choice is random among the sandboxes that qualify, so with more than one running you may land somewhere different each time. When there was more than one to choose from, miren names the one it picked — on a terminal only, so that piping or redirecting output never mixes miren's chatter into the sandbox's own.
An app with several services gets its `web` service, and only that one. If no web instance is up, miren says so rather than substituting a worker, since landing in the wrong kind of process is worse than being told to try again. Use `--service` to ask for a different one:
```bash
miren sandbox exec --app myapp --service worker -- ps aux
```
Within the service you asked for, miren prefers sandboxes running your app's **current** version, so a command run during a deploy won't drop you into the version you just replaced. That one is a preference rather than a rule: a failed deploy leaves the current version pointing at something that never came up while the previous instances keep serving traffic, and refusing those would lock you out of a shell exactly when you need one.
:::warning[Put -- before your command]
Anything after `--` is passed to the sandbox untouched. Without it, a flag meant for your command is read as a flag for miren — `miren sandbox exec --app myapp ls -la` fails on `-l`, and a `-h` anywhere before `--` prints this help instead of running anything.
:::
## Finding sandbox IDs
To target a specific sandbox instead, use `miren sandbox list` to find its ID:
```bash
$ miren sandbox list
ID APP SERVICE STATUS NODE
sandbox/myapp-web-abc123 myapp web RUNNING node-1
sandbox/myapp-web-def456 myapp web RUNNING node-2
```
:::warning[This is a live instance]
When you exec into a production sandbox, you're connecting to a live instance that may be serving traffic. Be careful with commands that could affect the running application.
:::
:::tip[Use app run to stay out of the way]
For debugging or one-off tasks without affecting production, use `miren app run` to create an isolated ephemeral sandbox instead.
:::
## Usage
```bash
miren sandbox exec [args...] [flags]
```
## Flags
- `--app, -a` — Pick a running sandbox belonging to this app
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--id, -i` — Sandbox ID
- `--service` — With --app, restrict the choice to one service (default: web)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Open a shell in a running sandbox:**
```bash
miren sandbox exec sb_abc123
```
**Run a command in a sandbox:**
```bash
miren sandbox exec sb_abc123 -- ls -la /app
```
**Let miren pick a running sandbox for an app:**
```bash
miren sandbox exec --app myapp
```
**Run a command in one of an app's worker sandboxes:**
```bash
miren sandbox exec --app myapp --service worker -- ps aux
```
## See also
- [`miren sandbox`](./sandbox.md)
---
## miren sandbox inspect
Show one sandbox's resource usage and failure history
## Usage
```bash
miren sandbox inspect [flags]
```
## Arguments
- `sandbox` — ID or short ID of the sandbox to inspect
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--series` — Include CPU and memory history
- `--since` — Measure over this window, e.g. 30s, 5m, 1h (default 1m)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Inspect a sandbox:**
```bash
miren sandbox inspect sb_abc123
```
**Inspect over the last hour:**
```bash
miren sandbox inspect sb_abc123 --since 1h
```
**Include CPU and memory history:**
```bash
miren sandbox inspect sb_abc123 --series
```
## See also
- [`miren sandbox`](./sandbox.md)
---
## miren sandbox list
List sandboxes (excludes dead by default)
List the sandboxes currently known to the cluster. Dead sandboxes are hidden unless you pass `--all` or ask for the `dead` status explicitly.
The JSON form is a stable inventory view with the same operational fields as the table. It deliberately excludes the raw sandbox execution spec, which contains resolved environment values and may include secrets. For low-level inspection, use the explicitly privileged `miren debug entity list -k sandbox` command.
## Usage
```bash
miren sandbox list [flags]
```
## Flags
- `--all, -a` — Include dead sandboxes (excluded by default)
- `--app` — Only show sandboxes belonging to this app
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--service` — Only show sandboxes of this service (e.g. web, worker)
- `--status, -s` — Filter by status (pending, not_ready, running, stopped, dead)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List running sandboxes:**
```bash
miren sandbox list
```
**Include dead sandboxes:**
```bash
miren sandbox list --all
```
**Show only one app's sandboxes:**
```bash
miren sandbox list --app myapp
```
**Show only one service of an app:**
```bash
miren sandbox list --app myapp --service worker
```
**List as JSON:**
```bash
miren sandbox list --format json
```
## See also
- [`miren sandbox`](./sandbox.md)
---
## miren sandbox-pool list
List all sandbox pools
## Usage
```bash
miren sandbox-pool list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all pools:**
```bash
miren sandbox-pool list
```
## See also
- [`miren sandbox-pool`](./sandbox-pool.md)
---
## miren sandbox-pool set-desired
Set desired instance count for a sandbox pool
## Usage
```bash
miren sandbox-pool set-desired [flags]
```
## Arguments
- `poolid` — Pool ID (e.g., pool-CUSkT8J58BmgkDeGyPP2e or pool/pool-CUSkT8J58BmgkDeGyPP2e)
- `desired` — Desired instance count (absolute number, +N to increase, or -N to decrease)
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--raw-id` — Use the provided ID as-is without adding the pool/ prefix
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Scale a pool to 3 instances:**
```bash
miren sandbox-pool set-desired web 3
```
## See also
- [`miren sandbox-pool`](./sandbox-pool.md)
---
## miren sandbox-pool
Sandbox pool management commands
## Usage
```bash
miren sandbox-pool [flags]
```
## Subcommands
- [`miren sandbox-pool list`](./sandbox-pool-list.md) — List all sandbox pools
- [`miren sandbox-pool set-desired`](./sandbox-pool-set-desired.md) — Set desired instance count for a sandbox pool
---
## miren sandbox stop
Stop a sandbox
## Usage
```bash
miren sandbox stop [flags]
```
## Arguments
- `sandboxid` — ID of the sandbox to stop
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Stop a sandbox by ID:**
```bash
miren sandbox stop sb_abc123
```
## See also
- [`miren sandbox`](./sandbox.md)
---
## miren sandbox
Sandbox management commands
Sandboxes are the underlying execution environments for your applications. Most of the time you'll work with apps directly, but these commands are useful for debugging and advanced use cases.
## Usage
```bash
miren sandbox [flags]
```
## Subcommands
- [`miren sandbox delete`](./sandbox-delete.md) — Delete a dead sandbox
- [`miren sandbox exec`](./sandbox-exec.md) — Open interactive shell in an existing sandbox
- [`miren sandbox inspect`](./sandbox-inspect.md) — Show one sandbox's resource usage and failure history
- [`miren sandbox list`](./sandbox-list.md) — List sandboxes (excludes dead by default)
- [`miren sandbox stop`](./sandbox-stop.md) — Stop a sandbox
---
## miren secret destroy
Permanently delete a version's value
Permanently deletes a version's value. Unlike `miren secret disable`, this cannot be undone: the encrypted payload is dropped, so anything still referencing the version can never resolve again.
Prefer `miren secret disable` when revoking a leaked credential — it fails closed just as hard, and leaves you able to recover if something still needed the value.
## Usage
```bash
miren secret destroy [ [flags]
```
## Arguments
- `ref` — Reference naming a version, e.g. payments/stripe-key@x1A
## Flags
- `--backend, -b` — Backend instance holding the secret (default: cluster)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--force, -f` — Skip the confirmation prompt
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Destroy a version's value for good:**
```bash
miren secret destroy payments/stripe-key@x1A
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret disable
Stop a version from resolving
Stops a specific version from resolving. Anything still referencing it fails closed on its next resolve rather than falling back to a different value — a revoked secret must never silently become a working one.
Disabling is reversible with `miren secret enable`; the value itself is untouched. To delete the value outright, use `miren secret destroy`.
## Usage
```bash
miren secret disable ][ [flags]
```
## Arguments
- `ref` — Reference naming a version, e.g. payments/stripe-key@x1A
## Flags
- `--backend, -b` — Backend instance holding the secret (default: cluster)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Revoke a leaked version:**
```bash
miren secret disable payments/stripe-key@x1A
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret enable
Let a disabled version resolve again
## Usage
```bash
miren secret enable ][ [flags]
```
## Arguments
- `ref` — Reference naming a version, e.g. payments/stripe-key@x1A
## Flags
- `--backend, -b` — Backend instance holding the secret (default: cluster)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Re-enable a version:**
```bash
miren secret enable payments/stripe-key@x1A
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret keyring
Show the cluster keyring and any rotation in flight
Shows the keys that encrypt this cluster's stored secrets: which one new writes use, how old each is, and how many stored versions are still wrapped by each.
A key other than the current one with versions still on it means a rotation is part-way through. That is normal and safe — every key in the ring can still decrypt what it wrapped, so nothing is unreadable while the backfill runs. The old key is retired only once its count reaches zero.
## Usage
```bash
miren secret keyring [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show the cluster keys:**
```bash
miren secret keyring
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret list
List stored secrets
## Usage
```bash
miren secret list [flags]
```
## Flags
- `--backend, -b` — Backend instance to list (default: cluster)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List all secrets:**
```bash
miren secret list
```
**List as JSON:**
```bash
miren secret list --format json
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret rotate-key
Rotate the cluster key that encrypts stored secrets
Rotates the cluster key that encrypts stored secrets, without waiting for the automatic age policy. This is what an incident calls for.
Rotation is not disruptive. A new key is minted and new writes use it immediately, then existing versions are re-wrapped onto it in the background — only the small wrapped data key is rewritten, never the secret values themselves, so the cost does not depend on how large your secrets are. Every version stays readable throughout, and the old key is retired only once nothing references it.
Watch progress with `miren secret keyring`.
:::note[Your secrets do not change]
Rotating the cluster key re-encrypts how values are stored. It does not change any secret's value or mint new versions, so nothing referencing a secret needs redeploying.
:::
## Usage
```bash
miren secret rotate-key [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rotate now, without waiting for the age policy:**
```bash
miren secret rotate-key
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret set
Store a secret value
Stores a value in the cluster's secret store. The value is encrypted at rest and is never echoed, logged, or written to disk by this command — it travels to the server, which holds the only key.
Each write mints a new immutable version and prints its handle, e.g. `payments/stripe-key@x1A`. Storing a value identical to the current one is reported as unchanged rather than minting a duplicate, so re-running the command is safe.
:::note[Rotation does not touch running apps]
Rotating a secret mints a new version but leaves anything already running on the value it started with. Old versions stay resolvable, so a rollback comes back on the value it originally shipped with.
:::
## Usage
```bash
miren secret set ] [flags]
```
## Arguments
- `path` — Secret path, e.g. payments/stripe-key
## Flags
- `--backend, -b` — Backend instance to store into (default: cluster)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--value` — Secret value, or @file to read one from a file. Prompts with masking when omitted, which is safer — a literal here is recorded in shell history. A trailing newline is trimmed
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Store a secret, prompting with masking:**
```bash
miren secret set payments/stripe-key
```
**Store a secret read from a file:**
```bash
miren secret set tls/cert --value @cert.pem
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret versions
Show a secret's versions
## Usage
```bash
miren secret versions [flags]
```
## Arguments
- `path` — Secret path, e.g. payments/stripe-key
## Flags
- `--backend, -b` — Backend instance to read (default: cluster)
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show every version of a secret:**
```bash
miren secret versions payments/stripe-key
```
## See also
- [`miren secret`](./secret.md)
---
## miren secret
Secret store management commands
## Usage
```bash
miren secret [flags]
```
## Subcommands
- [`miren secret destroy`](./secret-destroy.md) — Permanently delete a version's value
- [`miren secret disable`](./secret-disable.md) — Stop a version from resolving
- [`miren secret enable`](./secret-enable.md) — Let a disabled version resolve again
- [`miren secret keyring`](./secret-keyring.md) — Show the cluster keyring and any rotation in flight
- [`miren secret list`](./secret-list.md) — List stored secrets
- [`miren secret rotate-key`](./secret-rotate-key.md) — Rotate the cluster key that encrypts stored secrets
- [`miren secret set`](./secret-set.md) — Store a secret value
- [`miren secret versions`](./secret-versions.md) — Show a secret's versions
---
## miren server config generate
Generate a server configuration file from current settings
## Usage
```bash
miren server config generate [flags]
```
## Flags
- `--defaults, -d` — Generate config with default values
- `--mode, -m` — Server mode: standalone (default), distributed (experimental) (default: `standalone`)
- `--output, -o` — Output file path (defaults to stdout)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Generate config with defaults:**
```bash
miren server config generate --defaults
```
**Generate and save to file:**
```bash
miren server config generate --defaults --output server.toml
```
## See also
- [`miren server config`](./server-config.md)
---
## miren server config validate
Validate a server configuration file
## Usage
```bash
miren server config validate [flags]
```
## Flags
- `--file, -f` — Configuration file to validate
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Validate a config file:**
```bash
miren server config validate --file server.toml
```
## See also
- [`miren server config`](./server-config.md)
---
## miren server config
Server configuration management commands
## Usage
```bash
miren server config [flags]
```
## Subcommands
- [`miren server config generate`](./server-config-generate.md) — Generate a server configuration file from current settings
- [`miren server config validate`](./server-config-validate.md) — Validate a server configuration file
## See also
- [`miren server`](./server.md)
---
## miren server container install
Install miren server in a container
## Usage
```bash
miren server container install [flags]
```
## Flags
- `--allow-rootless` — Install even on a rootless runtime (control plane only; app workloads won't run)
- `--cluster-name` — Cluster name for cloud registration
- `--force, -f` — Remove existing container if present
- `--host-network` — Use host networking (ignores port mappings)
- `--http-port` — HTTP port mapping (default: `80`)
- `--image, -i` — Container image to use (full reference; mutually exclusive with --version)
- `--ingress-mode` — Ingress mode: tls-autoprovision (default), behind-proxy-http (Miren serves plain HTTP behind a TLS-terminating proxy like tailscale serve / nginx), or behind-proxy-https (Miren terminates TLS on :443 behind a TCP-passthrough proxy)
- `--labs, -l` — Miren Labs features to enable. Prefix with - to disable.
- `--name, -n` — Container name
- `--runtime` — Container runtime to use: docker or podman (auto-detected by default, preferring docker)
- `--url, -u` — Cloud URL for registration (default: `https://miren.cloud`)
- `--version, -V` — Image tag to install (e.g. v0.15.0 or main). Defaults to this CLI's release version, or main for main/HEAD builds; required for other dev builds
- `--without-cloud` — Skip cloud registration setup
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Install with cloud registration:**
```bash
miren server container install
```
**Install without cloud (local only):**
```bash
miren server container install --without-cloud
```
**Install with a custom HTTP port:**
```bash
miren server container install --http-port 8080
```
**Install behind a TLS-terminating proxy (e.g. tailscale serve):**
```bash
miren server container install --ingress-mode behind-proxy-http
```
**Force a specific runtime:**
```bash
miren server container install --runtime podman
```
**Install a specific release:**
```bash
miren server container install --version v0.15.0
```
## See also
- [`miren server container`](./server-container.md)
---
## miren server container status
Show status of miren server container
## Usage
```bash
miren server container status [flags]
```
## Flags
- `--follow, -f` — Follow logs in real-time
- `--name, -n` — Container name (default: `miren`)
- `--runtime` — Container runtime to use: docker or podman (auto-detected by default, preferring docker)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show status:**
```bash
miren server container status
```
**Follow logs:**
```bash
miren server container status --follow
```
## See also
- [`miren server container`](./server-container.md)
---
## miren server container uninstall
Uninstall miren server container
## Usage
```bash
miren server container uninstall [flags]
```
## Flags
- `--force, -f` — Force removal even if container is running
- `--name, -n` — Container name (default: `miren`)
- `--remove-volume` — Remove the data volume
- `--runtime` — Container runtime to use: docker or podman (auto-detected by default, preferring docker)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Uninstall the container:**
```bash
miren server container uninstall
```
**Uninstall and remove all data:**
```bash
miren server container uninstall --remove-volume
```
## See also
- [`miren server container`](./server-container.md)
---
## miren server container
Run the miren server in a container (Docker or Podman)
## Usage
```bash
miren server container [flags]
```
## Subcommands
- [`miren server container install`](./server-container-install.md) — Install miren server in a container
- [`miren server container status`](./server-container-status.md) — Show status of miren server container
- [`miren server container uninstall`](./server-container-uninstall.md) — Uninstall miren server container
## See also
- [`miren server`](./server.md)
---
## miren server identity-anchor
Move where this cluster's workload identity is anchored
## Usage
```bash
miren server identity-anchor [flags]
```
## Arguments
- `anchor` — Where to anchor workload identity: cluster or cloud
## Flags
- `--data-path, -d` — Server data path (default: `/var/lib/miren`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Let miren.cloud serve discovery for this cluster:**
```bash
miren server identity-anchor cloud
```
**Serve discovery from the cluster itself:**
```bash
miren server identity-anchor cluster
```
## See also
- [`miren server`](./server.md)
---
## miren server install
Install systemd service for miren server
## Usage
```bash
miren server install [flags]
```
## Flags
- `--address, -a` — Server address to bind to (default: `0.0.0.0:8443`)
- `--branch, -b` — Branch to download if release not found
- `--enroll-token` — Unattended enroll token from miren.cloud (registers without browser approval)
- `--force, -f` — Overwrite existing service file
- `--name, -n` — Cluster name for cloud registration
- `--no-start` — Do not start the service after installation
- `--skip-system-check` — Skip minimum system requirements check
- `--url, -u` — Cloud URL for registration (default: `https://miren.cloud`)
- `--verbosity` — Extra verbosity to bake into the unit (e.g. -v); the server defaults to Info without it
- `--without-cloud` — Skip cloud registration setup
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Install with cloud registration:**
```bash
miren server install
```
**Install without cloud (local only):**
```bash
miren server install --without-cloud
```
**Install with an unattended enroll token:**
```bash
miren server install --enroll-token "$(cat /etc/miren/enroll-token)"
```
## See also
- [`miren server`](./server.md)
---
## miren server operations abandon
Give up on an unfinished operation, including a data restore that keeps the server from starting
## Usage
```bash
miren server operations abandon [flags]
```
## Arguments
- `id` — Operation id
## Flags
- `--dir` — Operation directory (default: `/var/lib/miren/server/lifecycle`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Abandon an operation whose executor died:**
```bash
sudo miren server operations abandon 01J8X2M0QK4V6Z9W1N3RB5T7YC
```
## See also
- [`miren server operations`](./server-operations.md)
---
## miren server operations list
List recorded restart and upgrade operations
## Usage
```bash
miren server operations list [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dir` — Read operation records from this directory instead of asking the server (default: `/var/lib/miren/server/lifecycle`)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**List operations:**
```bash
miren server operations list
```
## See also
- [`miren server operations`](./server-operations.md)
---
## miren server operations run
Execute or resume an operation in the foreground (normally launched by miren upgrade)
## Usage
```bash
miren server operations run [flags]
```
## Flags
- `--dir` — Operation directory (default: `/var/lib/miren/server/lifecycle`)
- `--health-url` — Health endpoint to verify readiness against (default: derived from the server config)
- `--operation` — Operation id to execute or resume
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## See also
- [`miren server operations`](./server-operations.md)
---
## miren server operations show
Show one restart or upgrade operation
## Usage
```bash
miren server operations show [flags]
```
## Arguments
- `id` — Operation id
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--dir` — Read operation records from this directory instead of asking the server (default: `/var/lib/miren/server/lifecycle`)
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show an operation:**
```bash
miren server operations show 01J8X2M0QK4V6Z9W1N3RB5T7YC
```
## See also
- [`miren server operations`](./server-operations.md)
---
## miren server operations
Durable server restart and upgrade operations
## Usage
```bash
miren server operations [flags]
```
## Subcommands
- [`miren server operations abandon`](./server-operations-abandon.md) — Give up on an unfinished operation, including a data restore that keeps the server from starting
- [`miren server operations list`](./server-operations-list.md) — List recorded restart and upgrade operations
- [`miren server operations run`](./server-operations-run.md) — Execute or resume an operation in the foreground (normally launched by miren upgrade)
- [`miren server operations show`](./server-operations-show.md) — Show one restart or upgrade operation
## See also
- [`miren server`](./server.md)
---
## miren server register status
Show cluster registration status
## Usage
```bash
miren server register status [flags]
```
## Flags
- `--dir, -d` — Registration directory (default: `/var/lib/miren/server`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Check registration status:**
```bash
miren server register status
```
## See also
- [`miren server register`](./server-register.md)
---
## miren server register
Register this cluster with miren.cloud
## Usage
```bash
miren server register [flags]
```
## Flags
- `--enroll-token` — Unattended enroll token from miren.cloud
- `--name, -n` — Cluster name
- `--output, -o` — Output directory for registration (default: `/var/lib/miren/server`)
- `--url, -u` — Cloud URL (default: `https://miren.cloud`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Register with cloud:**
```bash
miren server register --name my-cluster
```
**Register with a specific cloud URL:**
```bash
miren server register --name my-cluster --url https://cloud.example.com
```
## Subcommands
- [`miren server register status`](./server-register-status.md) — Show cluster registration status
## See also
- [`miren server`](./server.md)
---
## miren server restart
Restart the systemd-managed miren server and wait for it to report ready
## Usage
```bash
miren server restart [flags]
```
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Restart the server:**
```bash
sudo miren server restart
```
## See also
- [`miren server`](./server.md)
---
## miren server status
Show miren service status
## Usage
```bash
miren server status [flags]
```
## Flags
- `--follow, -f` — Follow logs in real-time
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show server status:**
```bash
miren server status
```
**Follow server logs:**
```bash
miren server status --follow
```
## See also
- [`miren server`](./server.md)
---
## miren server uninstall
Remove systemd service for miren server
## Usage
```bash
miren server uninstall [flags]
```
## Flags
- `--backup-dir` — Directory to save backup tarball (default: `.`)
- `--remove-data` — Remove /var/lib/miren directory after backing it up
- `--skip-backup` — Skip backup when removing data (dangerous)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Uninstall the server:**
```bash
miren server uninstall
```
**Uninstall and remove all data:**
```bash
miren server uninstall --remove-data
```
## See also
- [`miren server`](./server.md)
---
## miren server unregister
Detach this cluster from miren.cloud
## Usage
```bash
miren server unregister [flags]
```
## Flags
- `--force, -f` — Skip the confirmation prompt
- `--local-only` — Clear local registration without telling miren.cloud
- `--output, -o` — Directory holding the registration (default: `/var/lib/miren/server`)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Unregister from cloud:**
```bash
miren server unregister
```
**Clear local registration when the cloud entry is already gone:**
```bash
miren server unregister --local-only
```
## See also
- [`miren server`](./server.md)
---
## miren server upgrade rollback
Rollback server to previous version
## Usage
```bash
miren server upgrade rollback [flags]
```
## Flags
- `--skip-health` — Skip health check after rollback
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Rollback to the previous version:**
```bash
miren server upgrade rollback
```
## See also
- [`miren server upgrade`](./server-upgrade.md)
---
## miren server upgrade
Upgrade miren server (deprecated: use 'sudo miren upgrade')
## Usage
```bash
miren server upgrade [flags]
```
## Flags
- `--channel` — Channel to use: 'latest' (stable releases, default) or 'main' (bleeding edge)
- `--check, -c` — Check for available updates only
- `--force, -f` — Force upgrade even if already up to date
- `--health-timeout` — Seconds to wait for the restarted server to report ready (default: `0`)
- `--no-auto-rollback` — Disable automatic rollback on failure (also skips the pre-upgrade etcd snapshot)
- `--release, -r` — Upgrade full release package (not just base)
- `--skip-health` — Deprecated: readiness is always verified
- `--version, -V` — Specific version to upgrade to (e.g., v0.2.0)
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Upgrade to the latest version:**
```bash
sudo miren server upgrade
```
**Check for available updates:**
```bash
sudo miren server upgrade --check
```
**Upgrade to a specific version:**
```bash
sudo miren server upgrade --version v0.2.0
```
## Subcommands
- [`miren server upgrade rollback`](./server-upgrade-rollback.md) — Rollback server to previous version
## See also
- [`miren server`](./server.md)
---
## miren server
Start the miren server
## Usage
```bash
miren server [flags]
```
## Flags
- `--acme-dns-provider` — DNS provider for ACME DNS-01 challenges (e.g., cloudflare, route53, exec). When set, uses DNS challenge instead of HTTP challenge. See https://go-acme.github.io/lego/dns/ for available providers.
- `--acme-email` — Email address for ACME account registration (recommended for account recovery and notifications)
- `--address, -a` — Address to listen on (host:port). For IPv6 use brackets, e.g. "[::1]:8443".
- `--app-version-retention-count` — Number of most-recent versions to retain per app regardless of age
- `--app-version-retention-period` — Retain versions newer than this duration regardless of count (e.g. 30d, 2w)
- `--buildkit-gc-duration` — How long to keep BuildKit cache entries (e.g., 7d, 24h)
- `--buildkit-gc-storage` — Maximum BuildKit layer cache size (e.g., 10GB, 50GB)
- `--buildkit-socket` — Path to external BuildKit Unix socket (for distributed mode)
- `--buildkit-socket-dir` — Directory for embedded BuildKit Unix socket (defaults to data_path/buildkit/socket)
- `--config` — Path to configuration file
- `--config-cluster-name, -C` — Name for this cluster in client config and as the telemetry label fallback when it is not registered with Miren Cloud
- `--containerd-binary` — Path to containerd binary
- `--containerd-socket` — Path to containerd socket
- `--data-path, -d` — Data path
- `--deployment-retention-count` — Number of most-recent deployment records to retain per app regardless of age
- `--deployment-retention-period` — Retain deployment records newer than this duration regardless of count (e.g. 30d, 2w). Set to 0 to keep them indefinitely.
- `--disk-mode` — Disk I/O mode: auto (default, detect from hardware), universal (loop devices), or accelerator (lbd devices)
- `--dns-names` — Additional DNS names assigned to the server cert
- `--etcd, -e` — Etcd endpoints
- `--etcd-client-port` — Etcd client port
- `--etcd-http-client-port` — Etcd HTTP client port
- `--etcd-peer-port` — Etcd peer port
- `--etcd-prefix, -p` — Etcd prefix
- `--etcd-quota-backend-bytes` — Etcd backend quota in bytes (0 = auto-size from system RAM)
- `--http-request-timeout` — HTTP request timeout in seconds
- `--ingress-address` — Optional bind override. Replaces the mode's default bind entirely (interface and port). Rejected by validation in tls-autoprovision (where :443 + :80 is structural). Reserved unix:/path prefix is not yet supported.
- `--ingress-mode` — Ingress mode: tls-autoprovision (default, :443 + :80 with ACME or self-signed), behind-proxy-http (plain HTTP for use behind a TLS-terminating proxy), behind-proxy-https (TLS terminated by Miren; certs come from self-signed or DNS-01 ACME, since :80 isn't bound for HTTP-01)
- `--ingress-trusted-proxy-hops` — Number of trusted proxies immediately in front of Miren when ingress.mode is behind-proxy-http. Used to select the visitor address from X-Forwarded-For.
- `--ips` — Additional IPs assigned to the server cert
- `--labs` — Comma-separated list of Miren Labs features to enable/disable. Prefix with - to disable.
- `--metrics-remote-write-audience` — Workload identity audience for the managed metrics destination
- `--metrics-remote-write-url` — Prometheus Remote Write destination for managed application metrics
- `--mode, -m` — Server mode: standalone (default), distributed (experimental)
- `--release-path` — Path to release directory containing binaries
- `--runner-address` — Runner address (host:port). For IPv6 use brackets, e.g. "[::1]:8444".
- `--runner-id, -r` — Runner ID
- `--saga-retention-period` — Delete finished saga executions older than this duration (e.g. 7d, 24h). Set to 0 to keep them indefinitely.
- `--secret-key-rotation-period` — Rotate the cluster key that encrypts stored secrets once it reaches this age (e.g. 90d, 1y). Set to 0 to rotate only on request.
- `--self-signed-tls` — Use self-signed certificates for TLS (for development/testing only)
- `--serve-tls` — Deprecated and ignored. Retained as a no-op so existing systemd unit files, env vars, and config files from pre-RFD-84 installs still parse. Use ingress.mode to pick the deployment shape.
- `--skip-client-config` — Skip writing client config file to clientconfig.d
- `--start-buildkit` — Start embedded BuildKit daemon for container image builds
- `--start-containerd` — Start embedded containerd daemon
- `--start-etcd` — Start embedded etcd server
- `--start-victorialogs` — Start embedded VictoriaLogs server
- `--start-victoriametrics` — Start embedded VictoriaMetrics server
- `--stop-sandboxes-on-shutdown` — Stop all sandboxes when server shuts down (useful in development)
- `--victorialogs-addr` — VictoriaLogs address (when not using embedded)
- `--victorialogs-http-port` — VictoriaLogs HTTP port in embedded mode
- `--victorialogs-retention` — VictoriaLogs retention period (e.g. 30d, 2w, 1y)
- `--victoriametrics-addr` — VictoriaMetrics address (when not using embedded)
- `--victoriametrics-http-port` — VictoriaMetrics HTTP port in embedded mode
- `--victoriametrics-retention` — VictoriaMetrics retention period in months
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Start in standalone mode:**
```bash
miren server --mode standalone
```
## Subcommands
- [`miren server config`](./server-config.md) — Server configuration management commands
- [`miren server container`](./server-container.md) — Run the miren server in a container (Docker or Podman)
- [`miren server identity-anchor`](./server-identity-anchor.md) — Move where this cluster's workload identity is anchored
- [`miren server install`](./server-install.md) — Install systemd service for miren server
- [`miren server operations`](./server-operations.md) — Durable server restart and upgrade operations
- [`miren server register`](./server-register.md) — Register this cluster with miren.cloud
- [`miren server restart`](./server-restart.md) — Restart the systemd-managed miren server and wait for it to report ready
- [`miren server status`](./server-status.md) — Show miren service status
- [`miren server uninstall`](./server-uninstall.md) — Remove systemd service for miren server
- [`miren server unregister`](./server-unregister.md) — Detach this cluster from miren.cloud
- [`miren server upgrade`](./server-upgrade.md) — Upgrade miren server (deprecated: use 'sudo miren upgrade')
---
## miren top
Show cluster-wide resource usage
Show what a cluster is spending its CPU and memory on, ordered by the busiest.
Each row is one live sandbox. Three replicas of a service are three rows, and
dead sandboxes are left out entirely. By default only app sandboxes are listed;
addon servers and one-off task runs are hidden unless you pass --system, so the
listing answers "what are my apps doing".
CPU is reported the way top reports it: as a percentage of one core. A sandbox
using two full cores reads 200%. NODE% is the same figure against the host it
runs on, which is what says whether 200% matters.
Four flags cover most investigations:
--nodes Which machine is hot, split into what sandboxes are using and what
Miren's own moving parts are using underneath them. Start here.
--apps One row per app instead of per sandbox, summed across everything
that app owns. See below.
--runner Narrow to one host once you know which.
--since Look back. A live view only shows now, so a spike that has already
passed is invisible; --since 1h --aggregate max finds it.
--apps answers "what is app X costing me" without you adding up rows. An app's
total includes the dedicated addons it owns -- a database exists because the app
asked for it -- and the SERVICES and ADDONS columns show how the total divides,
so "2.1 cores, 1.6 of it Postgres" is one line rather than an investigation.
Pass --no-addons to count only the app's own code.
App rows are read from the metrics store rather than from the sandboxes that
happen to be alive, so --since covers the whole window it names -- including the
deployment before the current one. An app with nothing left running is marked
"(gone)": it is in the listing only because the window reaches back far enough
to contain its samples. Samples are kept for a month, so a long enough --since
will name apps that have since been deleted.
--series adds that app's CPU and memory over time, which a row cannot show: a
row collapses the window to one number. --contributors breaks the figures down
by the sandboxes that produced them, including the ones already replaced. Both
need --apps and --app, because they answer for one app:
miren top --apps --app shop --since 168h --series --contributors
Use --format json to get the points and the breakdown as data, and --step to
choose the resolution of the history.
There is no restart column. Miren replaces a failed sandbox rather than
restarting it, so no single sandbox accumulates a restart count; repeated
failure shows up as a crash loop on the pool, which "miren sandbox inspect"
reports.
A row showing "-" for its numbers is running but reporting nothing. That is a
finding rather than an omission: either it has just started, or metrics
collection is down for it.
## Usage
```bash
miren top [flags]
```
## Flags
- `--aggregate` — How to collapse the window: avg, max, min, last (default: `avg`)
- `--app` — Only show sandboxes belonging to this app
- `--apps` — Show per-app totals instead of per-sandbox
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--contributors` — With --apps --app, list the sandboxes that made up the figures
- `--format` — Output format (text, json) (default: `text`)
- `--interval` — Refresh interval when watching (default: `5s`)
- `--json` — Shorthand for --format json
- `--kind` — Only show sandboxes of this kind (app, addon, run)
- `--limit` — Show at most this many rows (0 for all) (default: `0`)
- `--no-addons` — With --apps, exclude each app's dedicated addons from its total
- `--nodes` — Show per-node usage instead of per-sandbox
- `--order` — Sort direction: desc or asc (default: desc for usage, asc for names)
- `--runner` — Only show sandboxes on this runner (name, ID, or short ID)
- `--samples` — Stop after this many refreshes (0 for unlimited) (default: `0`)
- `--series` — With --apps --app, show one app's CPU and memory over time
- `--service` — Only show sandboxes of this service (e.g. web, worker)
- `--since` — Measure over this window, e.g. 30s, 5m, 1h (default 1m)
- `--sort` — Sort by: cpu, memory, name, app, service, node (default: `cpu`)
- `--status` — Only show sandboxes in this status
- `--step` — Resolution of --series, e.g. 30s, 5m (default: derived from --since)
- `--system` — Include addon and platform sandboxes
- `--watch, -w` — Refresh continuously until interrupted
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**What is using the cluster's CPU:**
```bash
miren top
```
**Which host is hot, and whether an app is to blame:**
```bash
miren top --nodes
```
**Per-app totals, including each app's dedicated addons:**
```bash
miren top --apps
```
**One app's usage:**
```bash
miren top --apps --app myapp
```
**Narrow to one runner:**
```bash
miren top --runner miren-garden-runner-1
```
**Find a spike that has already passed:**
```bash
miren top --since 1h --aggregate max
```
**Watch continuously:**
```bash
miren top --watch
```
**Machine-readable output:**
```bash
miren top --format json
```
---
## miren upgrade
Upgrade miren (the server or runner and the CLI on a systemd host, otherwise the CLI)
## Usage
```bash
miren upgrade [flags]
```
## Flags
- `--channel` — Channel to use: 'latest' (stable releases, the default except on a runner host) or 'main' (bleeding edge)
- `--check, -c` — Check for available updates only
- `--force, -f` — Upgrade even if already up to date; without root, upgrade only the CLI even though a server is running
- `--user, -u` — Install the CLI to ~/.miren/release/miren instead of the system location
- `--version, -V` — Specific version to upgrade to (e.g., v0.2.0). A runner host defaults to its coordinator's build
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Upgrade the CLI on a client machine:**
```bash
miren upgrade
```
**Upgrade the server or runner and the CLI on the host that runs it:**
```bash
sudo miren upgrade
```
**Check for updates without installing:**
```bash
miren upgrade --check
```
**Upgrade to a specific version:**
```bash
miren upgrade --version v0.2.0
```
---
## miren version
Print the version
## Usage
```bash
miren version [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--deps` — Show dependencies
- `--format` — Output format (text, json) (default: `text`)
- `--server, -s` — Also report the version of the active cluster's server
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Print version:**
```bash
miren version
```
**JSON output:**
```bash
miren version --format json
```
**Compare with the cluster's server:**
```bash
miren version --server
```
---
## miren whoami
Display information about the current authenticated user
## Usage
```bash
miren whoami [flags]
```
## Flags
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
## Global Options
- `--options` — Path to file containing options
- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`)
- `--verbose, -v` — Enable verbose output
## Examples
**Show current user:**
```bash
miren whoami
```
**JSON output:**
```bash
miren whoami --json
```
---
## Commands
Complete reference for all `miren` CLI commands.
## addon
| Command | Description |
|---------|-------------|
| [`miren addon`](./command/addon.md) | Addon management commands |
| [`miren addon create`](./command/addon-create.md) | Attach an addon to an application |
| [`miren addon destroy`](./command/addon-destroy.md) | Remove an addon from an application |
| [`miren addon list`](./command/addon-list.md) | List addons attached to an application |
| [`miren addon list-available`](./command/addon-list-available.md) | List available addons |
| [`miren addon rotate`](./command/addon-rotate.md) | Rotate an addon's backing credential |
| [`miren addon variants`](./command/addon-variants.md) | Show variants for an addon |
## admin
| Command | Description |
|---------|-------------|
| [`miren admin`](./command/admin.md) | Call an admin method on an application |
## alias
| Command | Description |
|---------|-------------|
| [`miren alias`](./command/alias.md) | CLI alias management |
| [`miren alias list`](./command/alias-list.md) | List configured CLI aliases |
## app
| Command | Description |
|---------|-------------|
| [`miren app`](./command/app.md) | Get information about an application |
| [`miren app attach`](./command/app-attach.md) | Attach to a running task |
| [`miren app delete`](./command/app-delete.md) | Delete an application and all its resources |
| [`miren app history`](./command/app-history.md) | Show deployment history for an application |
| [`miren app list`](./command/app-list.md) | List all applications |
| [`miren app restart`](./command/app-restart.md) | Restart an application |
| [`miren app run`](./command/app-run.md) | Open interactive shell in a new sandbox |
| [`miren app runs`](./command/app-runs.md) | List recent task runs |
| [`miren app runs cancel`](./command/app-runs-cancel.md) | End a run early |
| [`miren app set-workload-role`](./command/app-set-workload-role.md) | Set the API role for an app's sandbox identity tokens |
| [`miren app status`](./command/app-status.md) | Show current status of an application |
| [`miren app versions`](./command/app-versions.md) | List app versions with status |
## apps
| Command | Description |
|---------|-------------|
| [`miren apps`](./command/apps.md) | List all applications (alias for 'app list') |
## auth
| Command | Description |
|---------|-------------|
| [`miren auth`](./command/auth.md) | Authentication commands |
| [`miren auth ci`](./command/auth-ci.md) | CI authentication binding management |
| [`miren auth ci add`](./command/auth-ci-add.md) | Add a CI authentication binding to an application |
| [`miren auth ci list`](./command/auth-ci-list.md) | List CI authentication bindings for an application |
| [`miren auth ci remove`](./command/auth-ci-remove.md) | Remove a CI authentication binding |
| [`miren auth generate`](./command/auth-generate.md) | Generate authentication config file |
| [`miren auth provider`](./command/auth-provider.md) | Identity provider management |
| [`miren auth provider add`](./command/auth-provider-add.md) | Add an identity provider for route protection |
| [`miren auth provider add github`](./command/auth-provider-add-github.md) | Add a GitHub identity provider |
| [`miren auth provider add oidc`](./command/auth-provider-add-oidc.md) | Add an OIDC identity provider |
| [`miren auth provider add password`](./command/auth-provider-add-password.md) | Add a shared-password identity provider |
| [`miren auth provider list`](./command/auth-provider-list.md) | List identity providers |
| [`miren auth provider remove`](./command/auth-provider-remove.md) | Remove an identity provider |
| [`miren auth provider show`](./command/auth-provider-show.md) | Show an identity provider |
## cluster
| Command | Description |
|---------|-------------|
| [`miren cluster`](./command/cluster.md) | List configured clusters |
| [`miren cluster add`](./command/cluster-add.md) | Add a new cluster configuration |
| [`miren cluster available`](./command/cluster-available.md) | List the clusters Miren Cloud has for your account |
| [`miren cluster current`](./command/cluster-current.md) | Show the pinned cluster for this app |
| [`miren cluster export-address`](./command/cluster-export-address.md) | Export cluster address with TLS fingerprint for MIREN_CLUSTER |
| [`miren cluster list`](./command/cluster-list.md) | List all configured clusters |
| [`miren cluster remove`](./command/cluster-remove.md) | Remove a cluster from the configuration |
| [`miren cluster restart`](./command/cluster-restart.md) | Restart the active cluster's server and wait for it to report ready |
| [`miren cluster switch`](./command/cluster-switch.md) | Switch to a different cluster |
| [`miren cluster upgrade`](./command/cluster-upgrade.md) | Upgrade the active cluster's server and wait for it to report ready |
## config
| Command | Description |
|---------|-------------|
| [`miren config`](./command/config.md) | Configuration file management |
| [`miren config info`](./command/config-info.md) | Show configuration file locations and format |
| [`miren config load`](./command/config-load.md) | Load config and merge it with your current config |
## deploy
| Command | Description |
|---------|-------------|
| [`miren deploy`](./command/deploy.md) | Deploy an application |
| [`miren deploy cancel`](./command/deploy-cancel.md) | Cancel an in-progress deployment |
| [`miren deploy target`](./command/deploy-target.md) | List deployment targets |
| [`miren deploy target add`](./command/deploy-target-add.md) | Add a deployment target |
| [`miren deploy target list`](./command/deploy-target-list.md) | List deployment targets |
| [`miren deploy target remove`](./command/deploy-target-remove.md) | Remove a deployment target |
| [`miren deploy target set-default`](./command/deploy-target-set-default.md) | Set the default deployment target |
## disk
| Command | Description |
|---------|-------------|
| [`miren disk`](./command/disk.md) | Disk backup and recovery |
| [`miren disk backup`](./command/disk-backup.md) | Backup a disk to a snapshot file |
| [`miren disk list-deleted`](./command/disk-list-deleted.md) | List deleted disks available for recovery |
| [`miren disk restore`](./command/disk-restore.md) | Restore a disk from a snapshot file |
| [`miren disk undelete`](./command/disk-undelete.md) | Restore a recently deleted disk |
## doctor
| Command | Description |
|---------|-------------|
| [`miren doctor`](./command/doctor.md) | Diagnose miren environment and connectivity |
## download
| Command | Description |
|---------|-------------|
| [`miren download`](./command/download.md) | Download management commands |
| [`miren download release`](./command/download-release.md) | Download and extract miren release |
## env
| Command | Description |
|---------|-------------|
| [`miren env`](./command/env.md) | Environment variable management commands |
| [`miren env delete`](./command/env-delete.md) | Delete environment variables |
| [`miren env get`](./command/env-get.md) | Get an environment variable value |
| [`miren env list`](./command/env-list.md) | List all environment variables |
| [`miren env set`](./command/env-set.md) | Set environment variables for an application |
## help
| Command | Description |
|---------|-------------|
| [`miren help`](./command/help.md) | Show help for one or more commands |
## init
| Command | Description |
|---------|-------------|
| [`miren init`](./command/init.md) | Initialize a new application |
## login
| Command | Description |
|---------|-------------|
| [`miren login`](./command/login.md) | Authenticate with miren.cloud |
## logout
| Command | Description |
|---------|-------------|
| [`miren logout`](./command/logout.md) | Remove local authentication credentials |
## logs
| Command | Description |
|---------|-------------|
| [`miren logs`](./command/logs.md) | View logs (defaults to app logs) |
| [`miren logs app`](./command/logs-app.md) | View application logs |
| [`miren logs build`](./command/logs-build.md) | View build logs |
| [`miren logs run`](./command/logs-run.md) | View logs for a task run |
| [`miren logs sandbox`](./command/logs-sandbox.md) | View sandbox logs |
| [`miren logs system`](./command/logs-system.md) | View system logs |
## rollback
| Command | Description |
|---------|-------------|
| [`miren rollback`](./command/rollback.md) | Roll back to a previous version |
## route
| Command | Description |
|---------|-------------|
| [`miren route`](./command/route.md) | List all HTTP routes |
| [`miren route down`](./command/route-down.md) | Put an HTTP route into maintenance |
| [`miren route list`](./command/route-list.md) | List all HTTP routes |
| [`miren route protect`](./command/route-protect.md) | Protect an HTTP route with an identity provider |
| [`miren route remove`](./command/route-remove.md) | Remove an HTTP route |
| [`miren route set`](./command/route-set.md) | Create or update an HTTP route |
| [`miren route set-default`](./command/route-set-default.md) | Set an app as the default route |
| [`miren route show`](./command/route-show.md) | Show details of an HTTP route |
| [`miren route timeout`](./command/route-timeout.md) | Override the ingress request timeout for an HTTP route |
| [`miren route tls-check`](./command/route-tls-check.md) | Ask an app before issuing certificates for names under its route |
| [`miren route unprotect`](./command/route-unprotect.md) | Remove identity-provider protection from an HTTP route |
| [`miren route unset-default`](./command/route-unset-default.md) | Remove the default route |
| [`miren route up`](./command/route-up.md) | Bring an HTTP route out of maintenance |
| [`miren route waf`](./command/route-waf.md) | Manage WAF protection on an HTTP route |
## runner
| Command | Description |
|---------|-------------|
| [`miren runner`](./command/runner.md) | Runner management commands |
| [`miren runner cordon`](./command/runner-cordon.md) | Mark a runner unschedulable without stopping its sandboxes |
| [`miren runner drain`](./command/runner-drain.md) | Cordon a runner and evict its sandboxes onto other nodes |
| [`miren runner install`](./command/runner-install.md) | Install systemd service for miren runner |
| [`miren runner join`](./command/runner-join.md) | Join this machine to a coordinator as a runner |
| [`miren runner list`](./command/runner-list.md) | List all registered runners |
| [`miren runner operations`](./command/runner-operations.md) | Durable runner restart and upgrade operations |
| [`miren runner operations abandon`](./command/runner-operations-abandon.md) | Give up on an unfinished operation |
| [`miren runner operations list`](./command/runner-operations-list.md) | List recorded restart and upgrade operations |
| [`miren runner operations run`](./command/runner-operations-run.md) | Execute or resume an operation in the foreground (normally launched by miren upgrade) |
| [`miren runner operations show`](./command/runner-operations-show.md) | Show one restart or upgrade operation |
| [`miren runner reissue`](./command/runner-reissue.md) | Rotate this runner's certificate in place (requires a still-valid cert), keeping its identity |
| [`miren runner remove`](./command/runner-remove.md) | Remove a registered runner and clean up resources |
| [`miren runner service-status`](./command/runner-service-status.md) | Show miren-runner systemd service status |
| [`miren runner start`](./command/runner-start.md) | Start this machine as a distributed runner |
| [`miren runner status`](./command/runner-status.md) | Show runner health and configuration |
| [`miren runner token`](./command/runner-token.md) | Manage join tokens |
| [`miren runner token create`](./command/runner-token-create.md) | Create a join token for a runner |
| [`miren runner token list`](./command/runner-token-list.md) | List all join tokens |
| [`miren runner token revoke`](./command/runner-token-revoke.md) | Revoke a join token |
| [`miren runner uncordon`](./command/runner-uncordon.md) | Make a cordoned runner eligible for scheduling again |
| [`miren runner uninstall`](./command/runner-uninstall.md) | Remove systemd service for miren runner |
| [`miren runner upgrade`](./command/runner-upgrade.md) | Upgrade miren runner to the latest or specified version |
| [`miren runner upgrade rollback`](./command/runner-upgrade-rollback.md) | Rollback runner to previous version |
## sandbox
| Command | Description |
|---------|-------------|
| [`miren sandbox`](./command/sandbox.md) | Sandbox management commands |
| [`miren sandbox delete`](./command/sandbox-delete.md) | Delete a dead sandbox |
| [`miren sandbox exec`](./command/sandbox-exec.md) | Open interactive shell in an existing sandbox |
| [`miren sandbox inspect`](./command/sandbox-inspect.md) | Show one sandbox's resource usage and failure history |
| [`miren sandbox list`](./command/sandbox-list.md) | List sandboxes (excludes dead by default) |
| [`miren sandbox stop`](./command/sandbox-stop.md) | Stop a sandbox |
## sandbox-pool
| Command | Description |
|---------|-------------|
| [`miren sandbox-pool`](./command/sandbox-pool.md) | Sandbox pool management commands |
| [`miren sandbox-pool list`](./command/sandbox-pool-list.md) | List all sandbox pools |
| [`miren sandbox-pool set-desired`](./command/sandbox-pool-set-desired.md) | Set desired instance count for a sandbox pool |
## secret
| Command | Description |
|---------|-------------|
| [`miren secret`](./command/secret.md) | Secret store management commands |
| [`miren secret destroy`](./command/secret-destroy.md) | Permanently delete a version's value |
| [`miren secret disable`](./command/secret-disable.md) | Stop a version from resolving |
| [`miren secret enable`](./command/secret-enable.md) | Let a disabled version resolve again |
| [`miren secret keyring`](./command/secret-keyring.md) | Show the cluster keyring and any rotation in flight |
| [`miren secret list`](./command/secret-list.md) | List stored secrets |
| [`miren secret rotate-key`](./command/secret-rotate-key.md) | Rotate the cluster key that encrypts stored secrets |
| [`miren secret set`](./command/secret-set.md) | Store a secret value |
| [`miren secret versions`](./command/secret-versions.md) | Show a secret's versions |
## server
| Command | Description |
|---------|-------------|
| [`miren server`](./command/server.md) | Start the miren server |
| [`miren server config`](./command/server-config.md) | Server configuration management commands |
| [`miren server config generate`](./command/server-config-generate.md) | Generate a server configuration file from current settings |
| [`miren server config validate`](./command/server-config-validate.md) | Validate a server configuration file |
| [`miren server container`](./command/server-container.md) | Run the miren server in a container (Docker or Podman) |
| [`miren server container install`](./command/server-container-install.md) | Install miren server in a container |
| [`miren server container status`](./command/server-container-status.md) | Show status of miren server container |
| [`miren server container uninstall`](./command/server-container-uninstall.md) | Uninstall miren server container |
| [`miren server identity-anchor`](./command/server-identity-anchor.md) | Move where this cluster's workload identity is anchored |
| [`miren server install`](./command/server-install.md) | Install systemd service for miren server |
| [`miren server operations`](./command/server-operations.md) | Durable server restart and upgrade operations |
| [`miren server operations abandon`](./command/server-operations-abandon.md) | Give up on an unfinished operation, including a data restore that keeps the server from starting |
| [`miren server operations list`](./command/server-operations-list.md) | List recorded restart and upgrade operations |
| [`miren server operations run`](./command/server-operations-run.md) | Execute or resume an operation in the foreground (normally launched by miren upgrade) |
| [`miren server operations show`](./command/server-operations-show.md) | Show one restart or upgrade operation |
| [`miren server register`](./command/server-register.md) | Register this cluster with miren.cloud |
| [`miren server register status`](./command/server-register-status.md) | Show cluster registration status |
| [`miren server restart`](./command/server-restart.md) | Restart the systemd-managed miren server and wait for it to report ready |
| [`miren server status`](./command/server-status.md) | Show miren service status |
| [`miren server uninstall`](./command/server-uninstall.md) | Remove systemd service for miren server |
| [`miren server unregister`](./command/server-unregister.md) | Detach this cluster from miren.cloud |
| [`miren server upgrade`](./command/server-upgrade.md) | Upgrade miren server (deprecated: use 'sudo miren upgrade') |
| [`miren server upgrade rollback`](./command/server-upgrade-rollback.md) | Rollback server to previous version |
## top
| Command | Description |
|---------|-------------|
| [`miren top`](./command/top.md) | Show cluster-wide resource usage |
## upgrade
| Command | Description |
|---------|-------------|
| [`miren upgrade`](./command/upgrade.md) | Upgrade miren (the server or runner and the CLI on a systemd host, otherwise the CLI) |
## version
| Command | Description |
|---------|-------------|
| [`miren version`](./command/version.md) | Print the version |
## whoami
| Command | Description |
|---------|-------------|
| [`miren whoami`](./command/whoami.md) | Display information about the current authenticated user |
---
## Advanced / Debug Commands
:::warning
These commands are intended for advanced debugging and troubleshooting. They may change without notice.
:::
| Command | Description |
|---------|-------------|
| [`miren debug`](./command/debug.md) | Debug and troubleshooting commands |
| [`miren debug advertise`](./command/debug-advertise.md) | Show which addresses the server would advertise and why |
| [`miren debug bundle`](./command/debug-bundle.md) | Create a support bundle with system debug information |
| [`miren debug cloud-sync`](./command/debug-cloud-sync.md) | Show runtime entity sync diagnostics |
| [`miren debug colors`](./command/debug-colors.md) | Print some colors |
| [`miren debug connection`](./command/debug-connection.md) | Test connectivity and authentication with a server |
| [`miren debug ctr`](./command/debug-ctr.md) | Run ctr with miren defaults |
| [`miren debug ctr nuke`](./command/debug-ctr-nuke.md) | Nuke a containerd namespace |
| [`miren debug deploy-events`](./command/debug-deploy-events.md) | Render a 'miren deploy --format jsonl' stream as readable output |
| [`miren debug disk`](./command/debug-disk.md) | Disk entity debug commands |
| [`miren debug disk create`](./command/debug-disk-create.md) | Create a disk entity for testing |
| [`miren debug disk delete`](./command/debug-disk-delete.md) | Delete a disk entity |
| [`miren debug disk lease`](./command/debug-disk-lease.md) | Create a disk lease for testing |
| [`miren debug disk lease-delete`](./command/debug-disk-lease-delete.md) | Delete a disk lease entity |
| [`miren debug disk lease-list`](./command/debug-disk-lease-list.md) | List all disk lease entities |
| [`miren debug disk lease-release`](./command/debug-disk-lease-release.md) | Release a disk lease |
| [`miren debug disk lease-status`](./command/debug-disk-lease-status.md) | Show detailed status of a disk lease |
| [`miren debug disk list`](./command/debug-disk-list.md) | List all disk entities |
| [`miren debug disk mounts`](./command/debug-disk-mounts.md) | List all mounted disks from /proc/mounts |
| [`miren debug disk status`](./command/debug-disk-status.md) | Show status of a disk entity |
| [`miren debug entity`](./command/debug-entity.md) | Entity store debug commands |
| [`miren debug entity create`](./command/debug-entity-create.md) | Create a new entity |
| [`miren debug entity delete`](./command/debug-entity-delete.md) | Delete an entity |
| [`miren debug entity ensure`](./command/debug-entity-ensure.md) | Ensure an entity exists |
| [`miren debug entity get`](./command/debug-entity-get.md) | Get an entity |
| [`miren debug entity list`](./command/debug-entity-list.md) | List entities |
| [`miren debug entity patch`](./command/debug-entity-patch.md) | Patch an existing entity |
| [`miren debug entity put`](./command/debug-entity-put.md) | Put an entity |
| [`miren debug entity replace`](./command/debug-entity-replace.md) | Replace an existing entity |
| [`miren debug etcdctl`](./command/debug-etcdctl.md) | Run etcdctl against Miren's embedded etcd |
| [`miren debug netdb`](./command/debug-netdb.md) | Network database debug commands |
| [`miren debug netdb gc`](./command/debug-netdb-gc.md) | Find and release orphaned IP leases |
| [`miren debug netdb list`](./command/debug-netdb-list.md) | List all IP leases from netdb |
| [`miren debug netdb release`](./command/debug-netdb-release.md) | Manually release IP leases |
| [`miren debug netdb status`](./command/debug-netdb-status.md) | Show IP allocation status by subnet |
| [`miren debug rbac`](./command/debug-rbac.md) | Fetch and display RBAC rules from miren.cloud |
| [`miren debug rbac test`](./command/debug-rbac-test.md) | Test RBAC evaluation with fetched rules |
| [`miren debug reindex`](./command/debug-reindex.md) | Rebuild all entity indexes from scratch |
| [`miren debug saga`](./command/debug-saga.md) | Saga execution debug commands |
| [`miren debug saga list`](./command/debug-saga-list.md) | List saga executions |
| [`miren debug saga show`](./command/debug-saga-show.md) | Show a saga execution in detail |
| [`miren debug test`](./command/debug-test.md) | Debug test commands |
| [`miren debug test load`](./command/debug-test-load.md) | Loadtest a URL |
---
## Code of Conduct
# Miren Community Code of Conduct
## Our Pledge
We at Miren pledge to make our community welcoming, safe, and equitable for all.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Code of Conduct.
## Encouraged Behaviors
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language.
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including:
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Restricted Behaviors
Miren agrees to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people.
3. **Stereotyping or discrimination.** Characterizing anyone's personality or behavior on the basis of immutable identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community.
5. **Violating confidentiality.** Sharing or acting on someone's personal or private information without their permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials.** Sharing marketing or other commercial content in a way that is outside the norms of the community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, **you can private message any member of the Miren moderation team or staff.**
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution.
## Addressing and Repairing Harm
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped.
### 1. Warning
- **Event:** A violation involving a single incident or series of incidents.
- **Consequence:** A private, written warning from the Community Moderators.
- **Repair:** Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations.
### 2. Temporarily Limited Activities
- **Event:** A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation.
- **Consequence:** A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members.
- **Repair:** Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over.
### 3. Temporary Suspension
- **Event:** A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation.
- **Consequence:** A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
- **Repair:** Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
### 4. Permanent Ban
- **Event:** A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member.
- **Consequence:** Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior.
- **Repair:** There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community.
## Scope
This Code of Conduct applies to all Miren community spaces, including:
- GitHub repositories (issues, pull requests, discussions)
- Discord server
- Official social media accounts
- Community events (online and offline)
It also applies when an individual is officially representing the community in public spaces, such as using an official email address or acting as an appointed representative.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, available at [contributor-covenant.org/version/3/0](https://www.contributor-covenant.org/version/3/0/).
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
For answers to common questions about Contributor Covenant, see the [FAQ](https://www.contributor-covenant.org/faq). Translations are available at [contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla's code of conduct team](https://github.com/mozilla/inclusion).
---
## Deployment
Deployment is the core workflow of Miren. It takes your application source or configured container image and runs it on your server.
## Minimum working example
From the root of your project:
```miren
miren deploy
```
That's the whole thing. Miren first honors a Dockerfile selected by `[build].dockerfile` or discovered as `Dockerfile.miren`. Without one, it uses a configured image directly or detects your language and builds one on the server, then activates the new version. If the project isn't set up yet, the CLI offers to run `miren init` first; if the app doesn't exist on the server, it's created automatically on first deploy.
## How Deployment Works
When you run `miren deploy`, Miren:
1. **Uploads your files** — sends your project files and app configuration to the server (after your first deploy, only changed files are transferred)
2. **Selects or builds an image** — builds a configured or discovered Dockerfile first; otherwise uses a configured web image directly or inspects your source for a detected [language or framework](./guides/index.md)
3. **Activates the new version** — rolls out the new version, replacing the previous one
Each deployment is a tracked object with its own ID, a status, and the git commit it came from. A successful deployment produces a new version (identified by a version ID); a failed one produces no version but stays in history as a record of the attempt. You can inspect, roll back, or redeploy any previous version at any time.
## Deploying from a Project Directory
The most common workflow — run `miren deploy` from the root of your project:
```miren
cd ~/myapp
miren deploy
```
You can also deploy from a different directory with `-d`:
```miren
miren deploy -d path/to/app
```
Miren reads the app name from `.miren/app.toml`. If you haven't set up your project yet, Miren offers to run `miren init` for you. `miren init` creates `app.toml` with the app name derived from your directory, then scans the project for required environment variables and stages whatever it can — generated secrets, read-from-file values, and sensible defaults — on the app's initial config so they're available on the first deploy. See [What `miren init` Does for You](./app-configuration.md#what-miren-init-does-for-you) for the full picture. If this is the first deploy of the app, Miren creates it automatically on the server.
### Deployment targets
To keep deployment destinations in the repository, add a target from the app directory:
```miren
miren deploy target add staging
```
Choose a configured cluster from the picker. For scripts, supply its local name directly:
```miren
miren deploy target add prod my-prod-cluster
```
Miren creates `.miren/deploy.toml` alongside `app.toml`, recording both the local cluster name and its stable Miren Cloud ID:
```toml
[[targets]]
name = "staging"
cluster = "miren-staging"
cluster_id = "cluster-abc123"
[[targets]]
name = "prod"
cluster = "miren-prod"
cluster_id = "cluster-def456"
```
The first target is the default for `miren deploy`. Select another by name:
```miren
miren deploy --target prod
```
`cluster_id` is the stable Miren Cloud cluster ID. The CLI records it automatically for cloud-managed clusters, so the target resolves correctly even when different users added that cluster under different local names. The `cluster` name remains the fallback for clusters without a cloud ID.
Manage the file without editing TOML by hand:
```miren
miren deploy target # list targets
miren deploy target add qa # add via cluster picker
miren deploy target set-default prod
miren deploy target remove qa
```
An explicit `--cluster` (`-C`) or `MIREN_CLUSTER` overrides the default target. A named `--target` cannot be combined with either explicit cluster selection.
Selecting a deployment target affects that deploy only. It does not change the app's pinned cluster for commands such as `miren logs`.
### Confirmation Prompt
If your cluster config includes multiple clusters, Miren asks you to confirm which cluster to deploy to. Skip the prompt with `--force`:
```miren
miren deploy --force
```
The prompt is also skipped automatically when only one cluster is configured or when stdin is not a terminal (e.g., in CI).
## Build Detection
Miren automatically detects how to build your application. It inspects your project files and identifies the language, framework, package manager, and entry points. See [Language Guides](./guides/index.md) for details on supported stacks.
Use `--analyze` to see what Miren detects without actually building or deploying:
```miren
miren deploy --analyze
```
You'll see the selected source, services, entrypoint, and what files and frameworks influenced the result. Automatic builds name the detected stack, while image deploys show the normalized image reference:
```text
Source: python (auto-detected)
Source: dockerfile
Source: image ghcr.io/example/myapp:latest
```
The same source appears in `miren app status` after deployment, so it remains visible after the deploy finishes.
If a build fails, Miren displays the build errors and the deployment is marked as failed in history (visible via `miren app history`).
Use `--explain` (or `-x`) to watch each build step as it runs. This is the default in non-interactive environments; in interactive terminals, Miren shows a compact progress UI instead.
### Deploying an existing image
If your app is already packaged as a container image, set it on the `web` service:
```toml title=".miren/app.toml"
name = "myapp"
[services.web]
image = "ghcr.io/example/myapp:latest"
```
Miren normalizes the reference and launches that image directly. It does not run BuildKit, push a derived image, or create a build artifact. The container keeps the image's working directory, entrypoint, and command. If the image exposes exactly one TCP port, Miren uses it for the web service and injects the same value as `PORT`. Set `port` explicitly when the image exposes no ports or several.
:::note[Image selection]
A Dockerfile selected by `[build].dockerfile` or discovered as `Dockerfile.miren` takes precedence over `services.web.image`. Without one, the web image takes precedence over automatic source detection, even if the project directory also contains recognizable source files. Images on other services do not suppress source detection. If detection finds no buildable source, Miren uses a lone service image as the fallback; with several service images, set `services.web.image` to choose the primary one.
:::
## Deploy-Time Environment Variables
Set environment variables at deploy time with `-e` and `-s`. They're applied to the app before the new version activates.
```miren
# Regular variables
miren deploy -e RELEASE_SHA=abc123 -e LOG_LEVEL=debug
# Sensitive variables (masked in output)
miren deploy -s DATABASE_URL=postgres://user:pass@host/db
# Read value from a file
miren deploy -s API_KEY=@secrets/api-key.txt
# Prompt for the value (sensitive vars mask input)
miren deploy -s SECRET_KEY
```
## Redeploying an Existing Version
Skip image selection and building entirely by redeploying an existing version:
```miren
miren deploy --version myapp-vCVkjR6u7744AsMebwMjGU
```
Useful for rolling forward to a known-good version without waiting for a new image to resolve or build.
Find version IDs with `miren app history` (see [Deployment History](#deployment-history) below).
## Rollback
`miren rollback` provides an interactive way to revert to a previous version:
```miren
miren rollback -a myapp
```
This presents a picker showing your recent successful deployments:
| Column | Description |
|--------|-------------|
| VERSION | The version ID |
| STATUS | Deployment status (active, succeeded) |
| WHEN | Relative timestamp |
| GIT SHA | Short commit hash |
| BRANCH | Git branch (or jj bookmark) at the time of deploy |
Select a version and Miren redeploys it immediately. The currently active version is excluded from the list since rolling back to the current version would be a no-op.
Rollback creates a new deployment record — it doesn't erase history.
## Deployment History
View the history of deployments for an app:
```miren
miren app history -a myapp
```
### Output
```text
STATUS VERSION WHEN DEPLOYED BY
✓ myapp-vCVkjR6u7744AsMebwMjGU 2m ago paul@miren.dev
✓ myapp-vCVkjJSe4fydvxEHfhsKfA 1h ago paul@miren.dev
✗ myapp-vCVmuoeQCzjoNN9hGsu14c 3h ago paul@miren.dev
```
Status icons:
- **✓** — active (currently running) or succeeded
- **✗** — failed
- **⟳** — in progress
- **⊘** — cancelled
Show full git provenance with `--detailed`, or get JSON output for scripting. See the [`miren app history` reference](./command/app-history.md) for all options.
## Cancelling a Deployment
Cancel an in-progress deployment by its deployment ID:
```miren
miren deploy cancel -d
```
Get the deployment ID from `miren app history --detailed`. If a CLI session is watching that deployment, it'll detect the cancellation and exit cleanly.
:::warning[One deployment at a time]
Only one deployment can run per app at a time. If you attempt to deploy while another is in progress, Miren tells you who started it and when. You can wait for it to finish or cancel it and start a new one.
:::
## Git Provenance
Miren automatically captures git metadata (commit, branch, author, dirty state) from your working directory at deploy time. This information appears in `miren app history --detailed` and in the `miren rollback` picker. No configuration is needed — if you deploy from a git repo, provenance is captured automatically.
[Jujutsu](https://jj-vcs.github.io/jj/) workspaces are supported too, including non-colocated ones with no `.git` directory. The recorded commit is the parent of the working copy (`@-`), the branch is whichever local bookmark points at it, and the deploy counts as dirty when the working-copy commit (`@`) has changes on top of that parent. This matches what git reports in a colocated jj repo, so provenance looks the same either way.
If neither git nor jj can be found for the directory, the deploy still runs but warns that no provenance will be recorded, and the commit column in history shows a dash.
## Next Steps
- [Language Guides](./guides/index.md) — How Miren detects and builds different languages and frameworks
- [App Configuration](./app-configuration.md) — Configure your app with `.miren/app.toml`
- [Services](./services.md) — Define multiple processes in your app
- [CI/CD Deployment](./ci-deploy.md) — Deploy from CI pipelines with OIDC authentication
- [Pull Request Environments](./pr-environments.md) — Deploy labeled, time-boxed previews per PR
- [app.toml Reference](./app-toml.md) — Complete field reference
---
## Persistent Storage
Miren provides two options for persistent storage: **Local Storage** (simple, node-local) and **Miren Disks** (managed persistent volumes). Both are configured as disks in your `app.toml`. For a SQLite database, use the [`miren-sqlite` addon](./addons.md#sqlite-is-different) instead: it attaches its own storage and keeps it backed up, so there is no disk to declare.
Your data lives on the server where your app runs. Local storage and Miren Disks are backed up on request; a SQLite disk is backed up continuously as you write to it. See each section below.
## Minimum working example
Attach a persistent directory to a service in `.miren/app.toml`:
```toml
[services.web]
command = "node server.js"
[[services.web.disks]]
name = "data"
provider = "local"
mount_path = "/data"
```
Anything the service writes under `/data` survives restarts and redeploys. For a managed [Miren Disk](#miren-disks) with exclusive leasing and its own size, drop `provider = "local"` and set `size_gb` instead — and give the service fixed concurrency (`mode = "fixed"`, `num_instances = 1`), which miren disks require.
## Local Storage
Local storage gives your app a persistent directory on the server's filesystem. Data survives container restarts and redeployments.
### Configuration
Add a disk with `provider = "local"` to your service in `.miren/app.toml`:
```toml
[services.web]
command = "node server.js"
[[services.web.disks]]
name = "data"
provider = "local"
mount_path = "/miren/data/local"
```
You can mount to any path — for example, directly to a database's data directory:
```toml
[services.db]
image = "postgres:16"
[[services.db.disks]]
name = "pgdata"
provider = "local"
mount_path = "/var/lib/postgresql/data"
```
### How It Works
- **Persistent**: Data survives container restarts and redeployments
- **Shared**: All containers within your app share the same storage
- **Host-local**: Data lives on the server's filesystem
- **Node-pinned**: Apps with local storage are scheduled to the coordinator node
:::warning[Local disks share one per-app store]
Local storage is keyed per app, not per disk. Every `provider = "local"` disk your app declares — across all its services, regardless of the disk's `name` or `mount_path` — maps to the same directory on the host. Two local disks mounted at `/cache` and `/data` are two windows onto the same files: a write to one shows up in the other.
This is handy for sharing node-local state between an app's services, but if you want isolated areas, use subdirectories under a single local disk (for example `/data/cache` and `/data/uploads`) instead of declaring multiple local disks.
:::
### When to Use Local Storage
- File uploads and user content
- Application cache
- Session storage
- Any data that needs to persist across restarts
For a SQLite database, prefer the [`miren-sqlite` addon](./addons.md#sqlite-is-different): you get the same node-local directory plus continuous backup and automatic restore, without declaring a disk.
### Limitations
- **Host-local**: Data is tied to the server. If you move your app to a different server, you'll need to migrate the data manually.
- **No managed backups**: Back up your data by copying the host directory, or use your own backup tooling.
- **Shared access**: All containers in your app can read/write simultaneously—your application needs to handle concurrent access (SQLite handles this well when configured with `PRAGMA journal_mode=WAL`).
- **Node affinity**: Apps with any disk (local or miren) are pinned to the coordinator and won't be scheduled to [distributed runners](./distributed-runners.md).
### Migrating from Automatic Local Storage
Previously, Miren automatically mounted `/miren/data/local` for every app. This is now opt-in via the disk config above.
If any of your environment variables reference `/miren/data/local`, Miren will automatically add the local storage volume for you — so most apps will keep working without changes. You'll see a log message when this happens, and we recommend adding the explicit disk config when convenient.
---
## Miren Disks
:::note[Backups]
Miren Disks live on your server. Back up important data with `miren disk backup` and restore it with `miren disk restore`. Cloud backup is on the [roadmap](#roadmap-cloud-backup--sync).
:::
Miren Disks provide managed persistent storage for your applications. Disks are provisioned with a specific size and filesystem, support exclusive leasing for data consistency, and persist across app restarts and redeployments.
### Why Use Disks?
- **Managed lifecycle**: Miren handles disk creation, formatting, and attachment automatically
- **Configurable size and filesystem**: Specify exactly what you need
- **Thin provisioning**: Storage is allocated as needed, not all at once
- **Persist across redeployments**: Disks survive app deletion — reattach by name
### How Disks Work
When you configure a disk for your application:
1. **Miren creates the disk** with the size and filesystem you specify
2. **Your app instance acquires a lease** on the disk (exclusive access)
3. **The disk is mounted** at the path you specified in your container
When your app stops or restarts:
- The lease is released
- Data remains on the disk
- Your next instance can acquire the lease and continue where it left off
### How Much Storage Does Miren Provide?
During the Developer Preview, we're providing unmetered storage. The intention is to implement a free tier
and usage-based pricing on the storage. We'll be sure to communicate often and clearly how we intend
to proceed.
The feature is designed to keep our costs low, and our intention is to pass that low cost on to our users.
### Configuring Disks
Add a disk to your application by including a `disks` section in your service configuration in `.miren/app.toml`. Because a miren disk is leased exclusively to one instance, the service must use fixed concurrency with `num_instances = 1`:
```toml
[services.web]
image = "myapp:latest"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
[[services.web.disks]]
name = "my-app-data"
mount_path = "/data"
size_gb = 10
filesystem = "ext4"
```
#### Configuration Options
| Option | Required | Description |
|--------|----------|-------------|
| `name` | Yes | Unique name for the disk (alphanumeric, hyphens allowed) |
| `mount_path` | Yes | Where to mount the disk in your container |
| `size_gb` | Yes* | Size in gigabytes (required for auto-creation) |
| `filesystem` | No | Filesystem type: `ext4` (default), `xfs`, or `btrfs` |
| `read_only` | No | Mount as read-only (default: false) |
| `owner` | No | Ownership for a writable disk (default: the container's run user) |
*`size_gb` is required when the disk doesn't already exist. If the disk exists, this field is ignored.
:::tip[Disks are writable by the run user by default]
A writable disk is owned by the user your container runs as, so an image that
runs as a non-root user can write to its disk without a `chown` shim in the
entrypoint. Ownership is set on the top-level directory when it doesn't already
match, so there's no slow recursive walk on every boot.
Two cases keep their existing ownership untouched: a container that runs as root
(the disk already comes up root-owned, so there's nothing to fix) and a
`read_only` mount (its filesystem can't be written to anyway).
Set `owner` to override: `"keep"` leaves the raw mount ownership untouched, and
a numeric `"uid"` or `"uid:gid"` pins a specific owner.
:::
:::warning[Changing ownership on a large existing disk is a one-time pass]
Ownership is only rewritten when the disk's top-level directory doesn't already
match, and then the whole disk is walked to update it. That pass runs once and
scales with the number of files on the disk, so the first boot after a disk
starts being owned by a different user (for example, an existing disk first
mounted by a non-root image) can take a while to become ready. Later boots skip
the walk. Set `owner = "keep"` to opt out entirely.
:::
### Example: PostgreSQL with Persistent Storage
```toml
[services.db]
image = "postgres:16"
[services.db.concurrency]
mode = "fixed"
num_instances = 1
[[services.db.env]]
key = "POSTGRES_PASSWORD"
value = "secret"
[[services.db.env]]
key = "PGDATA"
value = "/var/lib/postgresql/data/pgdata"
[[services.db.disks]]
name = "myapp-postgres"
mount_path = "/var/lib/postgresql/data"
size_gb = 20
filesystem = "ext4"
```
### Example: File Upload Storage
```toml
[services.web]
image = "myapp:latest"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
[[services.web.disks]]
name = "myapp-uploads"
mount_path = "/app/uploads"
size_gb = 50
```
### Disk Lifecycle
#### Creation
Disks are automatically created when your app first deploys with a volume configuration that includes `size_gb`. The disk is provisioned with the specified size and filesystem.
#### Reuse
If you deploy an app with a `name` that matches an existing disk, Miren will attach that disk instead of creating a new one. This allows you to:
- Share data between app versions
- Preserve data across complete redeployments
- Reference disks created by other apps
#### Deletion
:::warning[Disks survive app deletion]
Disks are **not** automatically deleted when you delete an app. This is intentional - your data is precious.
:::
To delete a disk:
```miren
miren debug disk delete -i
```
### Inspecting Disks
List all disks:
```miren
miren debug disk list
```
Check a specific disk's status:
```miren
miren debug disk status -i
```
View active disk leases:
```miren
miren debug disk lease-list
```
See [CLI Reference - Disk Commands](./command/debug-disk.md) for complete command documentation.
### Important Considerations
#### One Instance per Disk
Disks use exclusive leasing - only one app instance can mount a disk at a time. This ensures data consistency but means:
- Multiple replicas of your app cannot share the same disk
- If you need shared storage, use separate disks per instance or external storage
#### Disk Sizing
- Disks use thin provisioning, so storage is only allocated as needed
- Choose a size that accommodates growth
#### Filesystem Choice
- **ext4**: Best general-purpose choice, widely compatible
- **xfs**: Better for large files and high-throughput workloads
:::note[mkfs tools required]
Your server must have the mkfs tools to format the disk types.
:::
### Roadmap: Cloud Backup & Sync
We're building toward cloud-connected storage for Miren Disks. Here's what's planned:
- **Remote backup & restore** (next up): Trigger backups of your disks to Miren Cloud and restore them on any cluster. This extends the existing local backup/restore functionality to work remotely.
- **Automatic cloud sync**: Background replication of disk data to Miren Cloud, enabling seamless portability across clusters.
We'll update this page and the [changelog](https://miren.md/changelog) as these capabilities land.
### Next Steps
- [app.toml Reference — Disks](./app-toml.md#disks) — Complete field reference for disk configuration (including `lease_timeout`)
- [Services](./services.md) — Define services that use persistent storage
- [Getting Started](./getting-started.md) — Deploy your first app
- [CLI Reference - Disk Commands](./command/debug-disk.md) — Complete disk CLI reference
- [Miren Cloud](./miren-cloud/overview.md) — Set up cloud features
---
## Distributed Runners
A Miren cluster starts as a single machine: one server that builds your apps, schedules them, and runs every sandbox. That's plenty to get going, but one machine has a ceiling. Eventually you run out of CPU and memory, and your only move is a bigger box.
Distributed runners are the way past that ceiling. You add more machines to the cluster, and Miren spreads your workloads across all of them, so you scale out instead of up.
## Minimum working example
Three commands take a cluster from one machine to two. On your workstation, mint a join token:
```miren
miren runner token create
```
On the machine you're adding, join with that token and install the runner as a service:
```miren
miren runner join mren_...
miren runner install
```
Once `miren runner list` shows the node ready, the scheduler starts placing sandboxes on it — nothing changes in your apps.
## How it works
A distributed cluster has two kinds of nodes: one **coordinator** and any number of **runners**.
The **coordinator** is the machine you started with, the one running `miren server`. It stays in charge of everything that has to live in one place: the entity store and its embedded etcd, the image registry, workload-identity signing, and the scheduler that decides where sandboxes run. The coordinator also runs your workloads itself, so a two-node cluster is really a coordinator plus one runner, not an idle controller plus one worker.
A **runner** is any other machine that has joined the cluster with `miren runner join`. Runners host sandboxes and nothing else: they pull the images they need, run your workloads, and report health back to the coordinator, but they hold no cluster state of their own. Every runner depends on the coordinator being reachable.
### Networking
Sandboxes across every node share a single flat overlay network. A sandbox on one runner can reach a sandbox on another by IP as if they were on the same host, so your services keep talking to each other the same way they did on a single machine. Miren handles the addressing, routes, and WireGuard peers, but your firewall must allow `51820/udp` between every pair of nodes.
:::info[Overlay address ranges]
Sandbox IPs are allocated from `10.8.0.0/16`, with each node leasing its own `/24` out of that range. Internal service addresses use `10.10.0.0/16`. Keep these ranges clear of your host and datacenter networks to avoid routing conflicts.
:::
### What runs where
The scheduler places each sandbox on a node when it starts. Two rules shape where things land:
- **Stateless workloads prefer runners.** Ordinary web and worker sandboxes are spread out across your runner nodes, keeping the coordinator free for the work only it can do. If no runners are available, they fall back to the coordinator.
- **Anything with a disk stays on the coordinator.** Disks, local storage, and host mounts are all node-local, so a sandbox that mounts one can't yet move between machines. Miren pins those sandboxes to the coordinator. See [Persistent Storage](./disks.md) for the details.
When an app runs several instances, the scheduler spreads them across nodes rather than stacking them on one, so losing a single machine doesn't take out your whole service.
### Images
Runners pull the images they need from the registry on the coordinator. There's no separate registry to run or configure, and no manual step to distribute an image to your runners. When you deploy, the coordinator resolves a configured image or builds one from source, and each runner that needs to start a sandbox pulls the selected image on demand.
## Adding a runner
Growing your cluster is three steps: mint a join token, join the new machine, and start it running.
### 1. Create a join token
From your workstation, create a token that authorizes a machine to join:
```miren
miren runner token create
```
This prints an `mren_...` token with the coordinator's address baked in. Tokens are one-time by default and expire after an hour. To provision several machines from the same token, pass `--reusable`, and adjust the lifetime with `--ttl`. See [`runner token create`](./command/runner-token-create.md) for the full set of options.
:::warning[Treat join tokens like secrets]
A join token lets any machine enroll as a runner in your cluster. Don't commit it, log it, or paste it anywhere it might be captured. Prefer one-time tokens, and revoke anything unused with [`runner token revoke`](./command/runner-token-revoke.md).
:::
### 2. Join the new machine
On the machine you want to add, run `join` with the token. The token has the coordinator's address baked in, so first make sure this machine can reach the coordinator there (port 8443 by default). In multi-cloud or split-network setups, that path isn't automatic. You can pass the token as an argument, or pipe it in over stdin to keep it out of your shell history:
```miren
miren runner join mren_...
```
Joining registers the machine as a runner, exchanges the token for a client certificate, and writes a config file to `/var/lib/miren/runner/config.yaml`. Each runner gets a stable identity; if you ever need to re-add a machine, remove the old entry first (see [caveats](#things-to-know) below). Reach for [`runner join`](./command/runner-join.md) for flags like `--name` and `--labels`.
Joining is only the first connection a runner makes. Once it's running it also
talks to the coordinator's etcd endpoint, so if there's a firewall between your
machines it needs to allow rather more than 8443. These are the defaults:
| Port | Protocol | What it carries | Protection |
|------|----------|-----------------|------------|
| 8443 | UDP | Coordinator API, including join and the metrics and logs a runner ships | Join token while enrolling, mTLS afterward |
| 12379 | TCP | etcd, for Flannel subnet coordination | mTLS |
| 51820 | UDP | WireGuard overlay | WireGuard encryption |
The coordinator API is QUIC, so 8443 is UDP rather than TCP. Opening the TCP
port instead is a common way to end up with a runner that can't join. The same
ports are listed in the [firewall reference](./firewall.md#between-nodes-distributed-runners).
Metrics and logs travel over 8443 alongside everything else a runner sends the
coordinator. VictoriaMetrics and VictoriaLogs themselves stay bound to loopback
on the coordinator and never need to be reachable from a runner, which is why
they aren't in that table. A runner authenticates each batch with a short-lived
identity token scoped to telemetry, on top of the certificate it got at join.
The overlay port needs to be open between runners, not just from each runner
to the coordinator, since sandboxes on different machines send traffic to each
other node-to-node. The coordinator's ports are configurable; see the
[server configuration reference](./server-config.md).
### 3. Start the runner
For a quick trial, start the runner in the foreground:
```miren
miren runner start
```
For anything you want to keep around, install it as a systemd service instead. This downloads the runner bundle, sets up the service, and keeps the runner running across reboots:
```miren
miren runner install
```
Back on your workstation, confirm the new node showed up and is healthy:
```miren
miren runner list
```
Once the runner reports ready, the scheduler starts placing sandboxes on it. There's nothing to change in your apps.
## Automating enrollment
Joining machines by hand is fine for a node or two. But once you're provisioning runners from Terraform or an autoscaling group, spinning them up and down as load shifts, you don't want a human in the loop for each one. The pieces are already here: a reusable token plus `runner install` gives a fresh machine everything it needs to enroll itself on first boot.
Start with a **reusable** token instead of the one-time kind. Create it once and hand the same token to every machine you provision:
```miren
miren runner token create --reusable --name infra --ttl 0
```
`--ttl 0` makes the token long-lived; set a real expiry like `--ttl 30d` if you'd rather rotate on a schedule. Store it wherever your infrastructure already keeps secrets.
:::danger[A reusable token is a standing key to your cluster]
Anyone holding it can enroll a runner, and it isn't consumed on use. Keep it in a secret manager, scope its TTL, and revoke it with [`runner token revoke`](./command/runner-token-revoke.md) the moment it's no longer needed or might have leaked.
:::
Then, in your machine's provisioning script (cloud-init, user data, an image build step), fetch the token and hand it to `runner install`. That single command downloads the runner, enrolls it, and sets up the systemd service, so the node comes up ready to take work:
```bash
# Skip if this machine already enrolled on a previous boot
if systemctl cat miren-runner.service >/dev/null 2>&1; then
echo "Runner already enrolled"
else
# Pull the reusable token from your secret store
TOKEN=$(your-secret-tool read miren/runner-token)
miren runner install \
--token "$TOKEN" \
--skip-system-check \
--force
fi
```
The `systemctl cat` guard keeps this idempotent. The service file lives on the boot disk, so it survives reboots and the script does nothing on a second run, while a freshly recreated machine has no service yet and enrolls cleanly. `--skip-system-check` stops the non-interactive install from pausing on a requirements prompt, and `--force` overwrites the service file left by any half-finished earlier attempt, so a retried boot installs cleanly. Add `--name ` to give the runner a readable identity in [`runner list`](./command/runner-list.md), and `--branch ` to pin a specific runtime version.
This is how we run Miren's own fleet: a reusable enrollment token in a secret manager, a Terraform module that bakes the `runner install` call into each instance's startup, and scaling the fleet up or down is just changing an instance count.
## Operating your runners
Day-to-day fleet management happens through the `runner` subcommands. A quick tour of the ones you'll reach for most:
- **Check on the fleet.** [`runner list`](./command/runner-list.md) shows every registered node and its health; [`runner status`](./command/runner-status.md) reports a single runner's health and configuration.
- **Take a node out of rotation.** [`runner cordon`](./command/runner-cordon.md) marks a runner unschedulable so no new sandboxes land on it, while leaving what's already running in place. [`runner uncordon`](./command/runner-uncordon.md) puts it back in rotation.
- **Empty a node.** [`runner drain`](./command/runner-drain.md) cordons a runner and then evicts its sandboxes so the pool controllers rebuild that capacity elsewhere. Use it before taking a machine down for maintenance.
- **Retire a node.** [`runner remove`](./command/runner-remove.md) deregisters a node and cleans up after it. Drain first, since remove refuses a node with active work unless you force it.
- **Keep runners current.** [`runner upgrade`](./command/runner-upgrade.md) updates a runner's binary in place.
- **Rotate credentials.** [`runner reissue`](./command/runner-reissue.md) rotates a runner's certificate without a full re-join, as long as its current certificate is still valid.
A typical maintenance window looks like: drain the node, do your work, then uncordon it (or remove it if it's not coming back).
## Things to know
A few properties of distributed clusters are worth keeping in mind as you plan:
- **The coordinator is the hub.** It holds the cluster state, the image registry, and the identity signer, and every runner depends on it. Runners keep their existing sandboxes running if the coordinator briefly goes away, but scheduling, deploys, and image pulls all need it back. For now that makes the coordinator a single point of coordination, so give it your most reliable machine. It won't stay that way: the control plane is built to grow into a multi-node setup that can survive losing a coordinator, and finishing that work is on our roadmap.
- **Stateful apps don't distribute yet.** Anything with a disk is pinned to the coordinator, so today distributed runners add capacity for stateless web and worker workloads, not for your databases. Letting stateful workloads migrate between nodes is something we're actively working toward. See [Persistent Storage](./disks.md).
- **Workload identity is issued by the coordinator.** Sandboxes on runners get their identity tokens by way of the coordinator, so token issuance depends on it being reachable and on an issuer being configured. See [Workload Identity](./workload-identity.md).
- **Metrics and logs flow to the coordinator.** Runners ship their sandboxes' metrics and logs back to the coordinator's observability stack, so everything lands in one place regardless of which node a sandbox ran on.
- **Re-adding a machine needs a clean slate.** Runner identities are unique. If you're rebuilding a machine that was previously a runner, remove the old registration with [`runner remove`](./command/runner-remove.md) before you join it again.
## Next steps
- [`miren runner`](./command/runner.md) — the full command reference for managing runners
- [Application Scaling](./scaling.md) — how Miren scales instances within your cluster
- [Persistent Storage](./disks.md) — why disks pin apps to the coordinator
---
## Firewall Configuration
:::info[Automatic configuration]
Miren automatically configures firewall rules during setup. Most users won't need to think about firewalls at all. This page is primarily useful for troubleshooting networking issues or understanding what Miren does under the hood.
:::
Miren automatically configures iptables rules to enable container networking. This page explains how Miren's firewall rules work and how to troubleshoot networking issues.
## Minimum working example
Host-level rules are managed for you. The only thing to configure yourself is your cloud provider's security group or network ACL — allow these inbound ports:
```text
8443/udp Miren API (QUIC) — CLI and client connections
80/tcp HTTP — ACME challenges and redirect to HTTPS
443/tcp HTTPS — application traffic
```
Also open any `node_port` values your apps declare for TCP/UDP services, and the [inter-node ports](#between-nodes-distributed-runners) if you run distributed runners.
## How Miren Configures Firewall Rules
:::info[Both iptables and nftables are required]
Miren uses `iptables` for the network bridge and per-sandbox NAT, and `nftables` (the `nft` command) for services and NodePorts. Both must be installed on the host — see [System Requirements](./system-requirements.md#required-host-commands).
:::
When Miren sets up the network bridge, it installs iptables rules in two chains:
### FORWARD Chain
The FORWARD chain controls traffic passing through the host (container-to-container and container-to-internet traffic). Miren adds rules to accept all traffic to and from the bridge interface:
```
-A FORWARD -i miren0 -j ACCEPT
-A FORWARD -o miren0 -j ACCEPT
```
### INPUT Chain
The INPUT chain controls traffic destined for the host itself. Miren adds rules to allow containers to reach host services:
| Port | Protocol | Purpose |
|------|----------|---------|
| 53 | UDP/TCP | DNS resolution (containers query host DNS) |
| 5000 | TCP | Local container registry (buildkit pushes images here) |
## Rule Ordering
Miren inserts rules at position 1 (the beginning of each chain) to ensure they take precedence over any existing restrictive rules. This is important because some cloud providers ship default firewall configurations with blanket REJECT rules.
For example, Oracle Cloud Ubuntu images have a REJECT rule at the start of the FORWARD chain by default. If Miren appended its ACCEPT rules to the end, they would never be evaluated.
## Required External Ports
If you're running Miren on a cloud provider, you'll need to configure security groups or network ACLs to allow external traffic to reach the Miren server.
### Inbound Ports
| Port | Protocol | Purpose | Required |
|------|----------|---------|----------|
| 8443 | UDP | Miren API (QUIC) - CLI and client connections | Yes |
| 80 | TCP | HTTP traffic to your applications (redirects to HTTPS) | Yes |
| 443 | TCP | HTTPS traffic to your applications | Yes |
| NodePorts | TCP/UDP | Direct L4 traffic to non-HTTP services (see [Traffic Routing](./traffic-routing.md)) | If using TCP/UDP services |
**Miren API (UDP 8443):** The Miren API uses QUIC (HTTP/3) over UDP. This is how the CLI communicates with the server and how remote clients connect to your cluster.
**HTTP Ingress (TCP 80/443):** Application traffic uses standard HTTP/HTTPS. Port 80 handles ACME certificate challenges and redirects to HTTPS. Port 443 serves your applications over TLS.
**NodePorts:** If your app exposes non-HTTP services with `node_port` in the port configuration, those ports must also be open. For example, an IRC server with `node_port = 6667` requires TCP port 6667 to be reachable.
### Between Nodes (Distributed Runners)
If you've grown the cluster with [distributed runners](./distributed-runners.md), the nodes also talk to each other. These are separate from the inbound ports above, and they only matter when there's a firewall between your machines.
| Port | Protocol | Direction | Purpose |
|------|----------|-----------|---------|
| 8443 | UDP | Runner to coordinator | Coordinator API: join, scheduling, and the metrics and logs a runner ships |
| 12379 | TCP | Runner to coordinator | etcd, for overlay subnet coordination |
| 51820 | UDP | Every node to every other node | WireGuard overlay carrying sandbox-to-sandbox traffic |
**The overlay port is the one people miss.** Sandboxes on different machines send traffic directly to each other, so 51820/udp has to be open between every pair of nodes, not just from each runner back to the coordinator.
**Telemetry doesn't need its own ports.** VictoriaMetrics and VictoriaLogs stay bound to loopback on the coordinator and are never reachable from a runner. A runner ships its metrics and logs over the coordinator API on 8443 like everything else it sends.
### Outbound Connectivity
Miren requires outbound internet access for several operations. Most cloud providers allow all outbound traffic by default, but if you have restrictive egress rules, ensure the following destinations are reachable:
| Destination | Port | Purpose |
|-------------|------|---------|
| oci.miren.cloud | 443 | Miren's container registry (base images for builds) |
| api.miren.cloud | 443 | Miren Cloud API (authentication, cluster registration) |
| registry-1.docker.io | 443 | Docker Hub (if your app references Docker Hub images) |
| Package registries | 443 | Language-specific package managers (see below) |
| Let's Encrypt | 80/443 | ACME certificate issuance |
**During builds**, Miren pulls base images from `oci.miren.cloud` for supported language stacks (Python, Ruby, Node.js, Go, Bun). If your application references other container images, those registries must also be reachable.
**Package managers** used during builds need access to their respective registries:
- **Ruby**: rubygems.org
- **Python**: pypi.org
- **Node.js/Bun**: registry.npmjs.org
- **Go**: proxy.golang.org (and any private module sources)
- **System packages**: debian/ubuntu apt repositories, Alpine apk repositories
**Miren Cloud** connectivity is required for authentication (`miren login`) and for cloud cluster registration (`miren server register`, or the `miren server install` / `miren server container install` commands when they register with cloud). Standalone installs (for example `miren server container install --without-cloud`) don't need it.
## Cloud Provider Considerations
### Oracle Cloud
Oracle Cloud instances come with restrictive default iptables rules. Miren handles this automatically by inserting rules at position 1, but be aware that:
- The default FORWARD chain has a REJECT rule that blocks all forwarding
- The default INPUT chain may not allow traffic from container networks
- You must also configure the VCN Security List to allow inbound traffic (see above)
### AWS
AWS security groups operate at the network layer outside the instance. Miren's iptables rules handle traffic inside the instance, but you still need to configure security groups to allow external traffic to reach your services (see above).
### Other Providers
Most cloud providers have some form of default firewall. Miren's rule insertion strategy should work out of the box, but if you encounter networking issues, check for conflicting rules. Remember to configure both:
1. **Cloud-level firewalls** (security groups, network ACLs) - allow external traffic to reach the instance
2. **Host-level firewalls** (iptables) - Miren handles this automatically
## Troubleshooting
### Symptoms of Firewall Issues
- "no route to host" errors during deploys
- Containers unable to resolve DNS
- Buildkit failing to push to local registry
- Intermittent network failures from sandboxes
### Diagnostic Commands
Check current iptables rules:
```bash
# View FORWARD chain
sudo iptables -L FORWARD -n -v --line-numbers
# View INPUT chain
sudo iptables -L INPUT -n -v --line-numbers
# Look for Miren's bridge interface rules (usually miren0)
sudo iptables -L -n -v | grep miren
```
Check if the bridge interface exists:
```bash
ip link show miren0
```
Verify containers can reach the host:
```bash
# From inside a sandbox, try to reach host DNS
ping 10.8.0.1 # (adjust IP to your bridge gateway)
```
### Common Issues
**Rules inserted in wrong order**: If you see REJECT rules before Miren's ACCEPT rules, the firewall may have been configured after Miren started. Restart Miren to re-insert rules at position 1.
**IPv6 issues**: Miren configures both IPv4 and IPv6 rules. If your environment doesn't support IPv6, you may see errors during bridge setup. This is usually harmless if IPv4 works.
**Conflicting firewall managers**: Tools like `ufw`, `firewalld`, or cloud-specific agents may conflict with Miren's iptables rules. If possible, configure these tools to allow Miren's traffic or disable them in favor of direct iptables management.
---
## Getting Started
Get up and running with Miren in minutes.
## Two Contexts: Server and Client
Miren runs in two places. The **server** is the Linux machine that hosts your applications. The **client** is wherever you work: your laptop, a CI runner, or even the server itself. Most `miren` commands are client commands that talk to a remote server, but a few (like `miren server install`) run directly on the server to set it up.
Throughout these docs, CLI examples are labeled SERVER or CLIENT so you always know which machine a command belongs on. When both labels appear, the command works in either context.
## Installation
Install Miren on both your server and your local machine. Head to [miren.dev/get-started](https://miren.dev/get-started) for platform-specific instructions and options.
Once installed, verify it's working on either machine:
```miren
miren version
```
### System Requirements (Server)
- **Operating System**: Linux (kernel 5.10+)
- **Architecture**: x86_64 or arm64
- **Memory**: 4GB minimum, 8GB recommended
- **Storage**: 50GB minimum, 100GB recommended
See [System Requirements](./system-requirements.md) for details on why these numbers matter.
## Set Up Your Server
(Skip this section if you are using our [demo cluster](#using-our-demo-cluster))
On your server, run the install command to set up the Miren runtime:
```miren
sudo miren server install
```
This will download required components, register your cluster with [miren.cloud](./miren-cloud/overview.md) (follow the prompts), install and start the Miren systemd service, and configure the local CLI to talk to it.
To skip cloud registration and run standalone:
```miren
sudo miren server install --without-cloud
```
### Using Our Demo Cluster
Ask for access to our demo cluster, [Miren Club](./miren-club.md), in #miren-club on [Discord](https://miren.dev/discord). Once you have access, log in and add the cluster:
```miren
miren login
```
Follow the prompts to authenticate with Miren Cloud, then bind the cluster to your local CLI:
```miren
miren cluster add
Select a cluster to bind:
NAME ORGANIZATION ADDRESS
▸ club Miren Club 34.27.122.56:8443 (+6)
```
## Deploy Your First App
Let's deploy a real app so you can see the full flow. Clone the [sample apps repo](https://github.com/mirendev/sample-apps) and navigate to the demo app:
```bash
git clone https://github.com/mirendev/sample-apps.git
cd sample-apps/demo
```
This is a small Bun web app that's already configured for Miren. Before deploying, you can preview what Miren detects:
```miren
miren deploy --analyze
```
This shows the detected stack, services, and how each service will be started. When you're ready, deploy it:
```miren
miren deploy
✓ Deploying: demo → club
✓ Upload artifacts (0.1s) - 13.4 KB at 180.5 KB/s
✓ Build & push image (7.8s) - 9 steps completed
Updated version demo-vCZpcrAAaU6mULzMSSBwc4 deployed. All traffic moved to new version.
No routes configured for this app.
To set a hostname, try: miren route set demo.cluster-jwomf2l0tn8z.miren.systems demo
```
Your app is deployed and running! Miren suggests a hostname on your cluster's `.miren.systems` subdomain. If your cluster is publicly accessible, go ahead and set that route:
```miren
miren route set demo.cluster-jwomf2l0tn8z.miren.systems demo
```
Then open the URL in your browser to see the demo app.

:::note[Not publicly accessible?]
If your server is behind a firewall or on a private network, `miren route set` still configures the route. To serve your apps to the internet without a public IP, [Miren Anywhere](./miren-cloud/miren-anywhere.md) routes traffic through Miren Cloud.
:::
Otherwise, verify the app is running locally with `miren app list` and `miren logs`, and see [Firewall](./firewall.md) for making the cluster directly reachable.
## See It Running
Check that your app deployed successfully:
```miren
miren app list
NAME VERSION SCALE ROUTE
demo demo-vCZpcrAAaU6mULzMSSBwc4 1 demo.cluster-jwomf2l0tn8z.miren.systems
```
You can also tail the logs to see requests coming in:
```miren
miren logs
S 2026-04-07 10:08:12: Server running at http://localhost:3000
S 2026-04-07 10:08:15: GET / 200 2ms
S 2026-04-07 10:08:15: GET /images/Miren-Logo-Secondary.svg 200 1ms
```
That's it! You have an app running on Miren.
## Next Steps
Now that you've got something deployed, here's where to go depending on what you need.
**Deploy your own app.** Miren auto-detects Python, Node, Bun, Go, Ruby, and Rust projects. Run `miren init` in your project to create a [`.miren/app.toml`](./app-configuration.md) config, then `miren deploy`. If your app already has a runnable container image, [configure it directly](./deployment.md#deploying-an-existing-image). Use a `Dockerfile` when the deployment still needs to add source, packages, or build steps. For a step-by-step source-build walkthrough, see the [Language Guides](./guides/index.md).
:::tip[Set up your own app with an agent]
Install the [Miren agent skills](./agent-skills.md) and ask your AI coding agent to
"set up this app on Miren." The `app-setup` skill inspects your project, works
out its services and environment variables, creates the configuration, and
walks through the first deploy.
:::
**Manage multiple clusters.** If you have more than one server, use [`miren cluster`](./command/cluster.md) to list your clusters and `miren cluster switch` to change which one you're targeting before deploying.
**Configure your app.** The [App Configuration](./app-configuration.md) guide covers `.miren/app.toml` in depth: setting commands, ports, environment variables, concurrency, and more.
**Set up routes.** Your first app gets a default route, but additional apps need explicit routing. See [Routes](./traffic-routing.md) for custom domains and path-based routing.
**Scale your app.** Miren autoscales by default (like Cloud Run), spinning instances up and down with traffic. If you need fixed instance counts for things like databases or workers, see [Application Scaling](./scaling.md).
**Add persistent storage.** [Disks](./disks.md) let you attach storage volumes to services, with built-in backup and restore.
**Explore the CLI.** The full [CLI Reference](./commands.md) documents every command and flag.
**Get help.** If something isn't working, check [Troubleshooting](./troubleshooting.md) or ask in #miren-club on [Discord](https://miren.dev/discord).
---
## Bash on Miren
Bash can't listen on a socket by itself, but it can write an HTTP response to standard
output — and that's all you need. This guide puts `socat` in front of a Bash script:
`socat` owns the socket, and runs the script for each connection.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this shell script on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren` and the socket
front-end, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead of
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The script
The script prints a complete HTTP response — status line, headers, a blank line, then
the body:
```bash
#!/usr/bin/env bash
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nHello from Bash on Miren!\n'
```
## The socket front-end
The script doesn't bind a port — `socat` does. Miren injects `PORT`, and `socat`'s
`TCP-LISTEN` binds all interfaces; `fork` runs the script once per connection. The
Dockerfile's `CMD` starts that socket front-end.
This same `socat` pattern serves any program that writes an HTTP response to stdout —
see the [COBOL guide](./cobol.md) for another example.
:::note[Behind Miren's ingress]
Miren's HTTP ingress terminates TLS and handles the public HTTP layer in front of your
app, so `socat` only needs to hand each accepted connection to your program — it isn't
exposed to raw internet traffic. The one practical limit is that `fork` spawns a process
per request, so this suits low-traffic endpoints and tooling rather than high-throughput
services.
:::
## The Dockerfile
Create `Dockerfile.miren` in your project root:
```dockerfile
FROM debian:12-slim
RUN apt-get update -y && apt-get install -y bash socat && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY hello.sh .
RUN chmod +x hello.sh
EXPOSE 8080
CMD ["sh", "-c", "exec socat TCP-LISTEN:$PORT,reuseaddr,fork EXEC:/app/hello.sh"]
```
### .dockerignore
```text
.git
```
## Deploy
Miren uses the image's `CMD` and infers the web port from `EXPOSE 8080`, so you don't
need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "bash-bench"
```
```miren
miren deploy
```
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Serving:** the script prints a full HTTP response; `socat` owns the socket
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `socat TCP-LISTEN:$PORT` binds `0.0.0.0`
- **Pattern:** works for any executable that writes an HTTP response to stdout
## Next steps
- [COBOL on Miren](./cobol.md) — the same `socat` pattern for a compiled program
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Deployment](../deployment.md) — how deploys build and activate
---
## C on Miren
C isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles your
program and runs the binary. This guide uses
[GNU libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/) — a small, embeddable
HTTP server library that's a stock Debian package (`libmicrohttpd-dev`), so there's
nothing to vendor or download.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this C app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect C, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it. `MHD_start_daemon` binds all interfaces
(`0.0.0.0`) on the given port; read `PORT` from the environment:
```c
#include
#include
#include
#include
#include
static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
const char *url, const char *method,
const char *version, const char *upload_data,
size_t *upload_data_size, void **con_cls) {
const char *page = "Hello from C on Miren!\n";
struct MHD_Response *response = MHD_create_response_from_buffer(
strlen(page), (void *)page, MHD_RESPMEM_PERSISTENT);
MHD_add_response_header(response, "Content-Type", "text/plain");
enum MHD_Result ret = MHD_queue_response(conn, MHD_HTTP_OK, response);
MHD_destroy_response(response);
return ret;
}
int main(void) {
const char *port_env = getenv("PORT");
int port = port_env ? atoi(port_env) : 8080;
struct MHD_Daemon *daemon = MHD_start_daemon(
MHD_USE_INTERNAL_POLLING_THREAD, port, NULL, NULL,
&on_request, NULL, MHD_OPTION_END);
if (!daemon) { fprintf(stderr, "failed to start daemon\n"); return 1; }
printf("listening on 0.0.0.0:%d\n", port);
fflush(stdout);
pause();
MHD_stop_daemon(daemon);
return 0;
}
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. Build and runtime both use `debian:12`,
so the library and glibc versions match — link against `libmicrohttpd-dev` at build
time and install the `libmicrohttpd12` runtime library:
```dockerfile
# ----- Build stage -----
FROM debian:12 AS build
RUN apt-get update -y && apt-get install -y gcc libmicrohttpd-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server.c .
RUN gcc -O2 -o app server.c -lmicrohttpd
# ----- Runtime stage -----
FROM debian:12-slim
RUN apt-get update -y && apt-get install -y libmicrohttpd12 && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/app /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
:::tip[Keep the build and runtime Debian versions in step]
Because both stages use `debian:12`, the compiled binary and the runtime `libc`/library
versions match. If you build on a newer image (e.g. `gcc:14`, which tracks Debian
trixie) and run on an older base, the binary crashes at startup with
`version 'GLIBC_2.38' not found`. Match the two, or statically link.
:::
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "c-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s API_TOKEN
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Library:** GNU libmicrohttpd from Debian (`libmicrohttpd-dev` build, `libmicrohttpd12` runtime)
- **Build:** `gcc -O2 -o app server.c -lmicrohttpd`; keep build + runtime on the same Debian release
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `getenv("PORT")`; `MHD_start_daemon` binds `0.0.0.0`
- **Env vars:** `miren env set -e/-s`; read with `getenv`
## Next steps
- [C++ on Miren](./cpp.md) — the C++ sibling guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Deployment](../deployment.md) — how deploys build and activate
---
## Clojure on Miren
Clojure isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[Ring](https://github.com/ring-clojure/ring) with the Jetty adapter, run via the
`clojure` CLI and `deps.edn`.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Clojure app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds Jetty to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Clojure, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`:
```clojure
;; src/app.clj — deps.edn searches "src" and the image CMD runs the `app` namespace
(ns app
(:require [ring.adapter.jetty :as jetty]))
(defn handler [_]
{:status 200
:headers {"Content-Type" "text/plain"}
:body "Hello from Clojure on Miren!\n"})
(defn -main []
(let [port (Integer/parseInt (or (System/getenv "PORT") "8080"))]
(jetty/run-jetty handler {:port port :host "0.0.0.0" :join? true})))
```
A `deps.edn` with the source path and dependencies:
```clojure
{:paths ["src"]
:deps {ring/ring-core {:mvn/version "1.12.2"}
ring/ring-jetty-adapter {:mvn/version "1.12.2"}}}
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. `clojure -P` downloads dependencies at
build time so they're cached in the image:
```dockerfile
FROM clojure:temurin-21-tools-deps
WORKDIR /app
COPY deps.edn .
RUN clojure -P
COPY . /app
EXPOSE 8080
CMD ["clojure", "-M", "-m", "app"]
```
### .dockerignore
```text
.git
.cpcache
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "clojure-bench"
```
```miren
miren deploy
```
For faster startup you can instead build an uberjar (via `tools.build` or `depstar`) and
run `java -jar`, but running the `clojure` CLI directly with cached deps works fine.
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `(System/getenv "KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `clojure:temurin-21-tools-deps`; `clojure -P` caches deps in the build
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `(System/getenv "PORT")`; `run-jetty handler {:host "0.0.0.0"}`
- **Env vars:** `miren env set -e/-s`; read with `(System/getenv "KEY")`
## Next steps
- [Java on Miren](./java.md) — the JVM sibling guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## COBOL on Miren
If it compiles to a program that can write bytes to a socket, Miren can run it — COBOL
included. COBOL has no built-in HTTP server, so this guide compiles a COBOL program with
[GnuCOBOL](https://gnucobol.sourceforge.io) that prints a complete HTTP response, and
puts `socat` in front of it to handle the socket.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this COBOL program on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren` and the socket
front-end, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead of
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The program
The COBOL program writes a full HTTP response — status line, headers, a blank line, then
the body — to standard output. `X"0D0A"` is CRLF:
```cobol
IDENTIFICATION DIVISION.
PROGRAM-ID. hello.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 CRLF PIC X(2) VALUE X"0D0A".
PROCEDURE DIVISION.
DISPLAY
"HTTP/1.1 200 OK" CRLF
"Content-Type: text/plain" CRLF
"Connection: close" CRLF
CRLF
"Hello from COBOL on Miren!" CRLF
WITH NO ADVANCING
STOP RUN.
```
## The socket front-end
The program itself doesn't listen on a port — `socat` does. For each connection, `socat`
runs the program and pipes its stdout back to the client. Miren injects `PORT`, and
`socat`'s `TCP-LISTEN` binds all interfaces. The Dockerfile's `CMD` starts that socket
front-end.
This `socat` pattern works for any language that can print an HTTP response to stdout.
:::note[Behind Miren's ingress]
Miren's HTTP ingress terminates TLS and handles the public HTTP layer in front of your
app, so `socat` only needs to hand each accepted connection to your program — it isn't
exposed to raw internet traffic. The one practical limit is that `fork` spawns a process
per request, so this suits low-traffic endpoints and tooling rather than high-throughput
services.
:::
## The Dockerfile
Create `Dockerfile.miren` in your project root. It installs GnuCOBOL and `socat`, then
compiles the program:
```dockerfile
FROM debian:12-slim
RUN apt-get update -y \
&& apt-get install -y gnucobol socat \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY hello.cob .
RUN cobc -x -o /app/hello hello.cob
EXPOSE 8080
CMD ["sh", "-c", "exec socat TCP-LISTEN:$PORT,reuseaddr,fork EXEC:/app/hello"]
```
### .dockerignore
```text
.git
```
## Deploy
Miren uses the image's `CMD` and infers the web port from `EXPOSE 8080`, so you don't
need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "cobol-bench"
```
```miren
miren deploy
```
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** `cobc -x -o /app/hello hello.cob` on `debian:12-slim` with `gnucobol`
- **Serving:** the program prints a full HTTP response; `socat` handles the socket
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `socat TCP-LISTEN:$PORT` binds `0.0.0.0`
- **Pattern:** the same `socat` front-end works for any language that writes an HTTP response to stdout
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Common Lisp on Miren
Common Lisp isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide
uses [SBCL](https://www.sbcl.org) with the [Hunchentoot](https://edicl.github.io/hunchentoot/)
web server, with dependencies pulled by Quicklisp.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Common Lisp app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds Hunchentoot
to `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead of
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`. The
script loads Quicklisp, defines a handler, starts the acceptor, and then blocks forever
so the process stays up:
```lisp
(load "/quicklisp/setup.lisp")
(ql:quickload :hunchentoot :silent t)
(setf hunchentoot:*dispatch-table*
(list (hunchentoot:create-prefix-dispatcher
"/"
(lambda ()
(setf (hunchentoot:content-type*) "text/plain")
(format nil "Hello from Common Lisp on Miren!~%")))))
(defvar *port* (parse-integer (or (uiop:getenv "PORT") "8080")))
(hunchentoot:start
(make-instance 'hunchentoot:easy-acceptor :port *port* :address "0.0.0.0"))
(format t "listening on 0.0.0.0:~a~%" *port*)
(loop (sleep 3600))
```
The final `(loop (sleep 3600))` matters — Hunchentoot's acceptor runs in a background
thread, so without it the script would finish and the process would exit.
## The Dockerfile
Create `Dockerfile.miren` in your project root. It installs Quicklisp and preloads
Hunchentoot during the build so startup is fast:
```dockerfile
FROM clfoundation/sbcl:latest
RUN apt-get update -y && apt-get install -y curl && rm -rf /var/lib/apt/lists/* \
&& curl -sO https://beta.quicklisp.org/quicklisp.lisp \
&& sbcl --non-interactive --load quicklisp.lisp \
--eval '(quicklisp-quickstart:install :path "/quicklisp")' \
&& sbcl --non-interactive --load /quicklisp/setup.lisp \
--eval '(ql:quickload :hunchentoot)'
WORKDIR /app
COPY . /app
EXPOSE 8080
CMD ["sbcl", "--disable-debugger", "--load", "/app/app.lisp"]
```
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "commonlisp-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `(uiop:getenv "KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `clfoundation/sbcl:latest`; install Quicklisp + preload Hunchentoot in the build
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Keep-alive:** end the script with `(loop (sleep 3600))` so the process stays running
- **Port:** `(uiop:getenv "PORT")`; `easy-acceptor :address "0.0.0.0"`
- **Env vars:** `miren env set -e/-s`; read with `(uiop:getenv "KEY")`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## C++ on Miren
C++ isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles your
program and runs the binary. This guide uses the header-only
[cpp-httplib](https://github.com/yhirose/cpp-httplib) library.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this C++ app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect C++, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and listen on `0.0.0.0`:
```cpp
#include "httplib.h"
#include
int main() {
httplib::Server svr;
svr.Get("/", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello from C++ on Miren!\n", "text/plain");
});
const char *p = std::getenv("PORT");
int port = p ? std::atoi(p) : 8080;
if (!svr.listen("0.0.0.0", port)) {
return 1; // bind/startup failed — surface it to the deploy
}
return 0;
}
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. It fetches the single `httplib.h` header
and compiles with pthreads:
```dockerfile
FROM gcc:14 AS build
WORKDIR /app
RUN apt-get update -y && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY main.cpp .
RUN curl -sSL -o httplib.h https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.18.3/httplib.h \
&& g++ -O2 -std=c++17 -pthread -o app main.cpp
FROM debian:trixie-slim
COPY --from=build /app/app /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
:::warning[Match the runtime to the compiler image]
The `gcc:14` image is built on Debian trixie (glibc 2.38, a newer `libstdc++`). Running
the binary on an older base like `debian:12-slim` fails at startup with
`GLIBC_2.38 not found` / `GLIBCXX_3.4.32 not found`. Use a runtime base from the **same
or newer** Debian release (`debian:trixie-slim`), or statically link the C++ runtime
with `-static-libstdc++ -static-libgcc`.
:::
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "cpp-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `std::getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** fetch `httplib.h`; `g++ -O2 -std=c++17 -pthread -o app main.cpp` on `gcc:14`
- **Runtime glibc/libstdc++:** use `debian:trixie-slim` (matches `gcc:14`) — older bases crash on `GLIBC_2.38`/`GLIBCXX_3.4.32`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `std::getenv("PORT")`; `svr.listen("0.0.0.0", port)`
- **Env vars:** `miren env set -e/-s`; read with `std::getenv`
## Next steps
- [C on Miren](./c.md) — the C sibling guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Deployment](../deployment.md) — how deploys build and activate
---
## Crystal on Miren
Crystal isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles
your app to a single static binary and runs it on a minimal image. This pattern works
for the standard-library HTTP server as well as frameworks like Kemal and Lucky.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Crystal app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms your
server binds `0.0.0.0:$PORT`, wires up environment variables, and deploys — using this
page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Crystal yet, so add a `Dockerfile.miren` to your project
root. Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so your server must read `PORT` and
listen on `0.0.0.0`. With the standard library:
```crystal
require "http/server"
port = (ENV["PORT"]? || "8080").to_i
server = HTTP::Server.new do |context|
context.response.content_type = "text/plain"
context.response.print "Hello from Crystal on Miren!\n"
end
server.bind_tcp "0.0.0.0", port
server.listen
```
Kemal reads `PORT` differently — pass it to `Kemal.run(port)` or set `Kemal.config.host_binding = "0.0.0.0"`.
## The Dockerfile
Create `Dockerfile.miren` in your project root. The build compiles a **static** binary
(`--static`), so the runtime image needs no Crystal toolchain. Replace `crystal_bench`
with the target name from your `shard.yml`:
```dockerfile
# ----- Build stage -----
FROM crystallang/crystal:1.14.0-alpine AS builder
WORKDIR /app
COPY . .
# Build the named target; the binary lands at bin/, copied below.
RUN shards build crystal_bench --release --static --no-debug
# ----- Runtime stage -----
FROM alpine:3.20
RUN apk add --no-cache gc pcre2 libgcc
COPY --from=builder /app/bin/crystal_bench /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
The Alpine-based Crystal image is what makes `--static` work (it links against musl).
`shards build` reads `shard.yml`; a minimal one looks like:
```yaml
name: crystal_bench
version: 0.1.0
targets:
crystal_bench:
main: src/app.cr
crystal: ">= 1.0.0"
```
### .dockerignore
```text
.git
bin
lib
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "crystal-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them in Crystal with `ENV["KEY"]`:
```miren
miren env set -s DATABASE_URL
miren env set -s SECRET_KEY
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
Need a managed Postgres database? Add a [`miren-postgresql` addon](../addons.md) and Miren
injects `DATABASE_URL` for you. See
[App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (static binary)
- **Build:** `shards build --release --static --no-debug` on `crystallang/crystal:*-alpine`
- **Runtime:** copy `bin/` to a minimal Alpine image
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** read `ENV["PORT"]`; bind `0.0.0.0`
- **Env vars:** `miren env set -e/-s`, or `[[env]]` in `app.toml`; read with `ENV["KEY"]`
- **Database:** optional `[addons.miren-postgresql]` injects `DATABASE_URL`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Addons](../addons.md) — managed Postgres and other backing services
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Dart on Miren
Dart isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles a
native executable and runs it on a minimal image. This guide uses the
[shelf](https://pub.dev/packages/shelf) server stack.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Dart app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Dart, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`:
```dart
void main() async {
final port = int.parse(Platform.environment['PORT'] ?? '8080');
handler(Request req) => Response.ok('Hello from Dart on Miren!\n');
await shelf_io.serve(handler, '0.0.0.0', port);
print('listening on 0.0.0.0:$port');
}
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. `dart compile exe` produces a native
binary:
```dockerfile
# ----- Build stage -----
FROM dart:stable AS builder
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart pub get --offline && dart compile exe bin/server.dart -o /app/server
# ----- Runtime stage (glibc for the compiled exe) -----
FROM debian:12-slim
RUN apt-get update -y && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/server /app/server
EXPOSE 8080
CMD ["/app/server"]
```
:::warning[Run the compiled exe on glibc, not scratch]
`dart compile exe` produces a dynamically-linked binary that needs a C library at
runtime. Running it on `scratch` fails to boot; use a small glibc image like
`debian:12-slim`. (Copying Dart's `/runtime/` directory is only needed for AOT
snapshots run with `dartaotruntime`, not for `compile exe`.)
:::
### .dockerignore
```text
.git
.dart_tool
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "dart-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `Platform.environment['KEY']`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (compiled exe)
- **Build:** `dart compile exe bin/server.dart -o /app/server` on `dart:stable`
- **Runtime:** `debian:12-slim` (glibc) — the compiled exe won't run on `scratch`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `Platform.environment['PORT']`; bind `0.0.0.0` via `shelf_io.serve`
- **Env vars:** `miren env set -e/-s`; read with `Platform.environment`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Deno on Miren
Miren auto-detects Node and Bun, but not Deno — so you deploy Deno with a
`Dockerfile.miren` built on the official Deno image. It runs TypeScript directly, so
there's no separate build step for most apps.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Deno app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms your
server binds `0.0.0.0:$PORT`, sets the runtime permissions, and deploys — using this
page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren's JavaScript detection covers Node and Bun (see
[JavaScript on Miren](./javascript.md)); Deno needs a `Dockerfile.miren`. Miren builds
from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so your server must read `PORT` and
listen on `0.0.0.0`. With the built-in `Deno.serve`:
```ts
const port = Number(Deno.env.get("PORT") ?? "8080");
Deno.serve({ port, hostname: "0.0.0.0" }, () =>
new Response("Hello from Deno on Miren!\n", {
headers: { "content-type": "text/plain" },
}));
```
Frameworks like Oak or Fresh accept the same `port`/`hostname` — pass the injected
`PORT` and bind `0.0.0.0`.
## The Dockerfile
Create `Dockerfile.miren` in your project root:
```dockerfile
FROM denoland/deno:2.1.4
WORKDIR /app
COPY . .
RUN deno cache main.ts
EXPOSE 8080
CMD ["run", "--allow-net", "--allow-env", "main.ts"]
```
`deno cache` pre-fetches and compiles your dependencies into the image so startup is
fast. Deno runs with no permissions by default, so grant exactly what your app needs —
`--allow-net` to listen, `--allow-env` to read `PORT`, and any others (`--allow-read`,
`--allow-write`) your code requires.
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "deno-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `Deno.env.get("KEY")` (needs `--allow-env`):
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
miren env set -s API_TOKEN
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (Node/Bun are detected, Deno is not)
- **Base image:** `denoland/deno:`; `deno cache main.ts` warms deps
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Permissions:** grant `--allow-net` + `--allow-env` at minimum; add others as needed
- **Port:** read `Deno.env.get("PORT")`; bind `0.0.0.0` via `Deno.serve`
- **Env vars:** `miren env set -e/-s`, or `[[env]]` in `app.toml`; read with `Deno.env.get`
## Next steps
- [JavaScript on Miren](./javascript.md) — Node and Bun (auto-detected)
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## .NET on Miren
.NET isn't auto-detected, so you deploy ASP.NET Core apps with a `Dockerfile.miren`
that publishes your app with the SDK image and runs it on the smaller ASP.NET runtime
image.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this .NET app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, points Kestrel at
`0.0.0.0:$PORT`, wires up environment variables, and deploys — using this page as its
reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect .NET, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so Kestrel must listen on `0.0.0.0` at
that port. The simplest way is to pass the URL to `app.Run`:
```csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello from .NET on Miren!\n");
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
app.Run($"http://0.0.0.0:{port}");
```
Reading `PORT` in code (as above) is the simplest approach. `ASPNETCORE_URLS` also works,
but Miren stores env vars literally and does **not** shell-expand `$PORT`, so you can't set
it to `http://0.0.0.0:$PORT` in `app.toml` — the app would receive that literal string. Use
it only from a shell entrypoint that expands `PORT` at runtime.
## The Dockerfile
Create `Dockerfile.miren` in your project root. Replace `dotnet-bench.dll` with your
project's assembly name:
```dockerfile
# ----- Build stage -----
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /out
# ----- Runtime stage -----
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=builder /out .
EXPOSE 8080
CMD ["dotnet", "dotnet-bench.dll"]
```
### .dockerignore
```text
.git
bin
obj
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "dotnet-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `Environment.GetEnvironmentVariable` or the
configuration system:
```miren
miren env set -e ASPNETCORE_ENVIRONMENT=Production
miren env set -s ConnectionStrings__Default
```
.NET maps `__` in an env var name to nested configuration keys, so
`ConnectionStrings__Default` becomes `ConnectionStrings:Default`. You can also declare
variables in `.miren/app.toml`:
```toml
[[env]]
key = "ASPNETCORE_ENVIRONMENT"
value = "Production"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** `dotnet publish -c Release -o /out` on the SDK image; run on `dotnet/aspnet`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `app.Run($"http://0.0.0.0:{port}")` (reading `PORT` in code; `ASPNETCORE_URLS` is not shell-expanded)
- **Env vars:** `miren env set -e/-s`; `__` maps to nested config keys
- **Database:** optional `[addons.miren-postgresql]` injects `DATABASE_URL`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Addons](../addons.md) — managed Postgres and other backing services
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Elixir on Miren
Elixir isn't auto-detected, so you deploy it with a `Dockerfile.miren` that builds a
Mix release. This guide uses Phoenix as the example — the same pattern works for any
Elixir release. The Dockerfile and steps below were validated end-to-end on a live
Miren cluster with a fresh `mix phx.new` app, Postgres addon, and migrations.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Phoenix app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It can generate the release
(`mix phx.gen.release`), drop in the `Dockerfile.miren`, wire up the database addon and
secrets, and deploy — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect the BEAM yet, so add a `Dockerfile.miren` to your
project root. Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Generate a release
If you're on Phoenix and haven't set up a release yet:
```bash
mix phx.gen.release
```
This generates `rel/overlays/bin/server` and `rel/overlays/bin/migrate`, which get
copied into the release root during `mix release`.
:::info[Version requirements]
Phoenix 1.8+ requires Elixir 1.15+ (1.18 recommended) and works well on Erlang/OTP 27.
Match the Elixir image's OTP suffix to your Erlang version.
:::
## The Dockerfile
Create `Dockerfile.miren` in your project root. Replace `my_app` with your OTP app name
(the `:app` in `mix.exs`):
```dockerfile
ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=27.3.4
ARG DEBIAN_CODENAME=bookworm
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_CODENAME}-20250929-slim"
ARG RUNNER_IMAGE="debian:${DEBIAN_CODENAME}-slim"
# ----- Build stage -----
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV="prod"
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
COPY rel rel
RUN mix compile
RUN mix assets.deploy
COPY config/runtime.exs config/
RUN mix release
# ----- Runtime stage -----
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
WORKDIR /app
RUN chown nobody /app
ENV MIX_ENV="prod"
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/my_app ./
USER nobody
ENV PHX_SERVER=true
EXPOSE 4000
CMD ["/app/bin/server"]
```
:::warning[Two easy-to-miss build steps]
`mix compile` must run **before** `mix assets.deploy` — Phoenix 1.8's colocated
LiveView JavaScript is generated during compile and esbuild needs it. And you must
`COPY rel rel` so the `bin/server` and `bin/migrate` overlay scripts exist in the
release. Skipping either produces a build that fails at runtime.
:::
Pick a `hexpm/elixir` tag that actually exists — not every date is published. Browse
[hub.docker.com/r/hexpm/elixir/tags](https://hub.docker.com/r/hexpm/elixir/tags).
### .dockerignore
Keep build artifacts out of the image context:
```text
.git
_build
deps
*.ez
erl_crash.dump
priv/static/assets
priv/static/cache_manifest.json
node_modules
.elixir_ls
.env
mise.toml
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and declaring the database addon
(covered in the next section):
```toml
name = "my_app"
[addons.miren-postgresql]
variant = "small"
```
Phoenix's generated `config/runtime.exs` already reads `PORT` (defaulting to 4000) and
binds the endpoint to `0.0.0.0`, so it works with Miren's injected `PORT` without changes.
Before you deploy, wire up the database and secrets the release needs at boot — see the
next section — then deploy from your project root:
```miren
miren deploy
```
## Environment variables
A production Phoenix app needs a database and a few secrets, and they must exist
**before the app boots** — `config/runtime.exs` raises on a missing `DATABASE_URL` or
`SECRET_KEY_BASE`. Set them before your first `miren deploy`.
:::warning[Set secrets before the app serves traffic]
A web app autoscales to zero, so a deploy can report success without ever starting an
instance — the missing-secret error only surfaces when the first request tries to boot
one, and the instance crashes. Configure the addon and secrets first.
:::
### Database via an addon
The simplest way to get `DATABASE_URL` is a managed Postgres [addon](../addons.md) — Miren
provisions it and injects the connection string (plus `PG*` variables) as environment
variables automatically. Declare it in `.miren/app.toml` (as shown in
[Deploy](#deploy)):
```toml
[addons.miren-postgresql]
variant = "small"
```
### Secrets and settings
Set the remaining variables with `miren env set` — `-s` masks secrets in output and logs:
```miren
miren env set -s SECRET_KEY_BASE
miren env set -e PHX_HOST=my_app.example.com
```
Generate `SECRET_KEY_BASE` with `mix phx.gen.secret` (or `openssl rand -base64 64`) and
paste it at the masked prompt. You can also set these at deploy time with
`miren deploy -s SECRET_KEY_BASE=... -e PHX_HOST=...`.
| Variable | Required | Notes |
|----------|----------|-------|
| `DATABASE_URL` | Yes | Injected by the `miren-postgresql` addon; or set manually (`ecto://USER:PASS@HOST/DATABASE`) |
| `SECRET_KEY_BASE` | Yes | Generate with `mix phx.gen.secret` |
| `PHX_HOST` | Yes | Public hostname used for URL generation |
| `PHX_SERVER` | Set in Dockerfile | Enables the HTTP server in the release |
| `PORT` | No | Injected by Miren; `runtime.exs` defaults to 4000 |
| `POOL_SIZE` | No | DB pool size, defaults to 10 |
| `DNS_CLUSTER_QUERY` | No | Enables Erlang clustering via DNS discovery |
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Migrations
The release includes a `migrate` script at `/app/bin/migrate` (from
`mix phx.gen.release`). Run it against your deployed app to apply migrations:
```miren
miren app run -a my_app -- /app/bin/migrate
```
## Clustering
A plain Phoenix app with controller routes is stateless — run multiple replicas behind
Miren's load balancer with no extra setup. Erlang clustering (for cross-node LiveView
or channels) is off unless you set `DNS_CLUSTER_QUERY` to a headless DNS name that
resolves all instance IPs, which activates the scaffolded `DNSCluster`.
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (multi-stage Mix release)
- **Release:** `mix phx.gen.release`; `mix compile` **before** `mix assets.deploy`; `COPY rel rel`
- **Runtime image:** `debian-slim` is fine — `mix release` bundles ERTS, so the runner needs no Erlang install
- **Runtime env:** `PHX_SERVER=true` in the Dockerfile; endpoint binds `0.0.0.0:$PORT` via `runtime.exs`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Secrets first:** set `SECRET_KEY_BASE` + `PHX_HOST` before the app serves traffic, or the instance crashloops
- **Database:** `[addons.miren-postgresql]` injects `DATABASE_URL` (and `PG*`) automatically
- **Migrations:** `miren app run -a -- /app/bin/migrate`
- **Image tags:** pick an existing `hexpm/elixir` tag; match the OTP suffix to your Erlang version
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Addons](../addons.md) — managed Postgres and other backing services
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Erlang on Miren
Erlang isn't auto-detected, so you deploy it with a `Dockerfile.miren` that builds a
rebar3 release and runs it on the BEAM. This guide uses [Cowboy](https://github.com/ninenines/cowboy)
as the HTTP server. (For Elixir and Gleam, which also run on the BEAM, see their own
guides: [Elixir](./elixir.md), [Gleam](./gleam.md).)
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Erlang app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds Cowboy to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Erlang, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Read the injected port
Miren injects `PORT` and routes traffic to it. In the application's `start/2`, read
`PORT` and start Cowboy's listener on it (Cowboy binds all interfaces by default):
```erlang
-module(erlang_bench_app).
-behaviour(application).
-export([start/2, stop/1]).
start(_Type, _Args) ->
Dispatch = cowboy_router:compile([
{'_', [{"/", erlang_bench_handler, []}]}
]),
Port = list_to_integer(os:getenv("PORT", "8080")),
{ok, _} = cowboy:start_clear(http_listener,
[{port, Port}],
#{env => #{dispatch => Dispatch}}),
erlang_bench_sup:start_link().
stop(_State) -> ok.
```
A minimal request handler:
```erlang
-module(erlang_bench_handler).
-export([init/2]).
init(Req0, State) ->
Req = cowboy_req:reply(200,
#{<<"content-type">> => <<"text/plain">>},
<<"Hello from Erlang on Miren!\n">>,
Req0),
{ok, Req, State}.
```
Declare Cowboy in `rebar.config` and the release:
```erlang
{deps, [{cowboy, "2.12.0"}]}.
{relx, [{release, {erlang_bench, "0.1.0"}, [erlang_bench, cowboy]},
{dev_mode, false},
{include_erts, true}]}.
```
`include_erts, true` bundles the runtime, so the release is self-contained.
## The Dockerfile
Create `Dockerfile.miren` in your project root. The build produces a release; the
runtime image just needs a couple of shared libraries the bundled ERTS links against:
```dockerfile
# ----- Build stage -----
FROM erlang:27 AS builder
WORKDIR /app
COPY . .
RUN rebar3 release
# ----- Runtime stage -----
FROM debian:12-slim
RUN apt-get update -y && apt-get install -y libncurses6 libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/_build/default/rel/erlang_bench /app
EXPOSE 8080
CMD ["/app/bin/erlang_bench", "foreground"]
```
### .dockerignore
```text
.git
_build
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "erlang-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `os:getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (rebar3 release)
- **Build:** `rebar3 release` on `erlang:27`; `include_erts, true` makes it self-contained
- **Runtime libs:** `libncurses6 libssl3` on `debian-slim`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `os:getenv("PORT", "8080")`; `cowboy:start_clear(_, [{port, Port}], _)`
- **Env vars:** `miren env set -e/-s`; read with `os:getenv`
## Next steps
- [Elixir on Miren](./elixir.md) and [Gleam on Miren](./gleam.md) — other BEAM guides
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## F# on Miren
F# isn't auto-detected, so you deploy it with a `Dockerfile.miren` — the same .NET
toolchain as [C#](./dotnet.md), just with F# source. This guide uses an ASP.NET Core
minimal API; the pattern also works for Giraffe and Saturn.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this F# app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, points Kestrel at
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect .NET, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so Kestrel must listen on `0.0.0.0` at
that port. Pass the URL to `app.Run`:
```fsharp
open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
[]
let main args =
let builder = WebApplication.CreateBuilder(args)
let app = builder.Build()
app.MapGet("/", Func(fun () -> "Hello from F# on Miren!\n")) |> ignore
let port = Environment.GetEnvironmentVariable("PORT")
let port = if String.IsNullOrEmpty(port) then "8080" else port
app.Run(sprintf "http://0.0.0.0:%s" port)
0
```
An `.fsproj` using the web SDK:
```xml
net9.0
```
(F# compiles files in listed order, so keep `Program.fs` last.)
## The Dockerfile
Create `Dockerfile.miren` in your project root. Replace `fsharp-bench.dll` with your
project's assembly name:
```dockerfile
# ----- Build stage -----
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /out
# ----- Runtime stage -----
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=builder /out .
EXPOSE 8080
CMD ["dotnet", "fsharp-bench.dll"]
```
### .dockerignore
```text
.git
bin
obj
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "fsharp-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs):
```miren
miren env set -e ASPNETCORE_ENVIRONMENT=Production
miren env set -s ConnectionStrings__Default
```
`__` in an env var name maps to nested configuration keys. You can also declare
variables in `.miren/app.toml`:
```toml
[[env]]
key = "ASPNETCORE_ENVIRONMENT"
value = "Production"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (same toolchain as [C#](./dotnet.md))
- **Build:** `dotnet publish -c Release -o /out` on the SDK image; run on `dotnet/aspnet`
- **fsproj:** use `Microsoft.NET.Sdk.Web`; list `` files in dependency order
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `app.Run(sprintf "http://0.0.0.0:%s" port)` (reading `PORT` in code; `ASPNETCORE_URLS` is not shell-expanded)
- **Env vars:** `miren env set -e/-s`; `__` maps to nested config keys
## Next steps
- [.NET on Miren](./dotnet.md) — the C# sibling guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Gleam on Miren
Gleam isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles
your project to an Erlang shipment and runs it on the BEAM. This guide targets Erlang
(the default for server apps using `mist`/`wisp`).
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Gleam app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It can add the `Dockerfile.miren`, confirm your
server binds `0.0.0.0:$PORT`, wire up environment variables, and deploy — using this
page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect the BEAM yet, so add a `Dockerfile.miren` to your
project root. Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::info[Validated pattern — adapt the versions]
The Dockerfile and app below were deployed to a live Miren cluster with Gleam 1.15 and
`mist` v6. Adjust the Gleam/Erlang image tags and your app name to match your project —
check
[github.com/gleam-lang/gleam/pkgs/container/gleam](https://github.com/gleam-lang/gleam/pkgs/container/gleam)
for available tags, and keep the runtime Erlang version in sync with the builder (see
the warning below the Dockerfile).
:::
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so your HTTP server must read `PORT`
and listen on `0.0.0.0`. With `mist` (used directly or under `wisp`), read the port
from the environment — the [`envoy`](https://hexdocs.pm/envoy/) package is the usual
way. This example is validated against `mist` v6:
```gleam
pub fn main() {
let port =
envoy.get("PORT")
|> result.try(int.parse)
|> result.unwrap(8080)
let assert Ok(_) =
handle_request
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(port)
|> mist.start
process.sleep_forever()
}
fn handle_request(_req: Request(Connection)) -> Response(ResponseData) {
response.new(200)
|> response.set_header("content-type", "text/plain")
|> response.set_body(mist.Bytes(bytes_tree.from_string("Hello from Gleam!\n")))
}
```
:::warning[Bind to the injected port]
Bind `0.0.0.0` and use the `PORT` value — an app that hardcodes a port or `localhost`
won't receive traffic.
:::
The `mist` builder API changes between major versions; on older releases the final call
is `mist.start_http` rather than `mist.start`. Add the dependencies with
`gleam add mist envoy`.
## The Dockerfile
Create `Dockerfile.miren` in your project root. The erlang shipment is copied whole, so
nothing here depends on your `gleam.toml` `name`:
```dockerfile
ARG GLEAM_VERSION=v1.15.0
# ----- Build stage -----
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder
WORKDIR /build
COPY . .
RUN gleam export erlang-shipment
# ----- Runtime stage -----
# The Erlang major version here MUST match the OTP version in the builder image
# above, or the compiled BEAM files fail to load at startup.
FROM erlang:28-alpine
RUN apk add --no-cache ca-certificates libstdc++ openssl ncurses-libs
WORKDIR /app
COPY --from=builder /build/build/erlang-shipment ./
EXPOSE 8080
# entrypoint.sh is generated by `gleam export erlang-shipment`
CMD ["./entrypoint.sh", "run"]
```
`gleam export erlang-shipment` produces `build/erlang-shipment/` containing your
compiled app, its dependencies, and an `entrypoint.sh`. Running `./entrypoint.sh run`
boots the release.
:::warning[Match the runtime Erlang version to the builder]
BEAM bytecode won't load on an older Erlang than it was compiled with. The pinned
`gleam:v1.15.0-erlang-alpine` builder ships **OTP 28**, so the runtime stage uses
`erlang:28-alpine`. Whenever you bump the Gleam image tag, check the OTP version it
ships (`docker run --rm ghcr.io/gleam-lang/gleam:-erlang-alpine erl -eval
'io:format("~s~n",[erlang:system_info(otp_release)]),halt().' -noshell`) and set the
runtime image to the same major version.
:::
### .dockerignore
Keep build artifacts out of the image context:
```text
.git
build
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
The command is the release entrypoint the shipment generated. Use the absolute path
(`/app/entrypoint.sh`, matching the Dockerfile's `WORKDIR /app`); the script resolves
its own release directory, so it works regardless of the working directory.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "gleam-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked
in output and logs). Read them in Gleam with `envoy.get("KEY")`:
```miren
miren env set -s DATABASE_URL
miren env set -s SECRET_KEY
```
`miren env set -s SECRET_KEY` (no value) prompts with masked input. You can also declare
variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
Need a managed Postgres database? Add a [`miren-postgresql` addon](../addons.md) and Miren
injects `DATABASE_URL` for you. See
[App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (Erlang shipment)
- **Build:** `gleam export erlang-shipment` → `build/erlang-shipment/` with `entrypoint.sh`
- **Runtime image:** Erlang major version **must match** the builder's OTP (the pinned v1.15.0 image ships OTP 28 → `erlang:28-alpine`)
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** read `PORT` via `envoy.get("PORT")`; bind `mist`/`wisp` to `0.0.0.0`
- **mist API:** v6 ends the builder with `mist.start`; older versions use `mist.start_http`
- **Env vars:** `miren env set -e/-s`, or `[[env]]` in `app.toml`; read with `envoy.get/1`
- **Database:** optional `[addons.miren-postgresql]` injects `DATABASE_URL`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Addons](../addons.md) — managed Postgres and other backing services
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Go on Miren
Miren auto-detects Go apps from `go.mod`, builds them to a single binary at
`/bin/app`, and ships it on a minimal runtime image — no Dockerfile required.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Go app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It finds your main package, proposes a start
command, wires up environment variables, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
No. Miren detects Go from `go.mod` and compiles the binary for you. The Go version
comes from the `go` directive in your `go.mod` (falling back to 1.23). Provide a
`Dockerfile.miren` only for custom build steps — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
## Set up the app
From your project root:
```miren
miren init
miren deploy
```
Preview what Miren detects — main package, version, entrypoint — without building:
```miren
miren deploy --analyze
```
### Build process
Miren builds on the official Go image, runs `go mod download` (skipped when a `vendor/`
directory is present), and compiles the binary to `/bin/app`. The builder already ships
`git` and `ca-certificates`, so public modules resolve with no extra setup.
:::warning[Private modules need vendoring or a Dockerfile]
The automatic Go build can't authenticate to a private module host. It doesn't read
`GOPRIVATE`, a `.netrc`, or git credentials, and variables you set with `miren env set`
are not injected into the module download step. If your project depends on private
modules, either commit a `vendor/` directory — Miren then builds with `-mod=vendor` and
skips the download entirely — or use a [`Dockerfile.miren`](./index.md#using-dockerfilemiren)
where you control how modules are fetched.
:::
### Which package gets built
Miren looks for your main package in `cmd/`:
1. If `cmd/` has a single subdirectory, that one is built.
2. If `cmd/` has a subdirectory matching the app name, that one is built.
3. Otherwise, Miren builds from the project root.
If your project has a `vendor/` directory, Miren builds with `-mod=vendor` for faster,
network-free builds.
### Start command
The compiled binary is at `/bin/app`. Your server must bind to `0.0.0.0` on `$PORT` —
Miren injects `PORT` and routes traffic to it. Read it with `os.Getenv("PORT")`:
```go
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Fatal(http.ListenAndServe("0.0.0.0:"+port, nil))
```
Use a `Procfile` to set flags or define additional processes:
```procfile
# Run the compiled binary
web: /bin/app
# With flags
web: /bin/app -addr=0.0.0.0:$PORT
# Background worker
worker: /bin/app -mode=worker
# Scheduler
scheduler: /bin/app -mode=scheduler
```
See [Services](../services.md) for running multiple processes.
### Runtime files
The runtime image is minimal but carries your non-Go files (READMEs, templates,
migrations, static assets, data directories, and other assets) alongside the binary so
the app can read them at runtime relative to `/app`. Go source (`*.go`) along with
`go.mod`, `go.sum`, and `vendor/` are excluded — the compiled binary needs none of them.
:::tip[Prefer go:embed for required assets]
If your app depends on files it must have at runtime, embed them with
[`go:embed`](https://pkg.go.dev/embed) rather than relying on them being copied. Embedded
files are compiled into the binary and always present.
:::
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked
in output and logs):
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
miren env set -s API_TOKEN
```
`miren env set -s API_TOKEN` (no value) prompts with masked input. You can also declare
variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** `go.mod` in the project
- **Version:** parsed from the `go` directive in `go.mod` (fallback 1.23)
- **Binary:** built to `/bin/app`; main package resolved from `cmd/` (see rules above)
- **Vendored deps:** `vendor/` present → build uses `-mod=vendor`
- **Start command:** `web: /bin/app`; bind `0.0.0.0:$PORT` via `os.Getenv("PORT")`
- **Runtime files:** non-Go files carried to `/app`; prefer `go:embed` for required assets
- **Env vars:** `miren env set -e KEY=VALUE`, `-s` for secrets, or `[[env]]` in `app.toml`
- **Dockerfile:** not needed; add `Dockerfile.miren` only for custom builds
## Next steps
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Services](../services.md) — web + workers
- [Deployment](../deployment.md) — how deploys build and activate
---
## Haskell on Miren
Haskell isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles a
binary with Cabal and runs it on a minimal image. This guide uses the
[Scotty](https://hackage.haskell.org/package/scotty) web framework as the example.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Haskell app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, wires up environment variables, and deploys — using this page as
its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Haskell, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it. Scotty (via Warp) binds to all interfaces
already, so you just read `PORT`:
```haskell
{-# LANGUAGE OverloadedStrings #-}
module Main where
main :: IO ()
main = do
portStr <- lookupEnv "PORT"
let port = maybe 8080 read portStr
scotty port $
get "/" $ text "Hello from Haskell on Miren!\n"
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. The build uses the official `haskell`
image (GHC + Cabal), then copies the binary onto a slim runtime:
```dockerfile
# ----- Build stage -----
FROM haskell:9.6.6 AS builder
WORKDIR /app
COPY . .
RUN cabal update
RUN cabal build
RUN cp "$(cabal list-bin haskell-bench)" /app/app.bin
# ----- Runtime stage -----
FROM debian:12-slim
RUN apt-get update -y \
&& apt-get install -y libgmp10 zlib1g ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/app.bin /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
`cabal list-bin ` prints the built binary's path, which the build copies to a
stable location. Replace `haskell-bench` with your executable name from the `.cabal`
file. A minimal `.cabal`:
```cabal
cabal-version: 2.4
name: haskell-bench
version: 0.1.0
executable haskell-bench
main-is: Main.hs
hs-source-dirs: app
build-depends: base, scotty, text
default-language: Haskell2010
ghc-options: -threaded -rtsopts "-with-rtsopts=-N"
```
:::warning[Warp needs the threaded runtime]
`-threaded` in `ghc-options` is required. Scotty runs on Warp, which uses GHC's event
manager — without the threaded runtime the app builds and starts but crashes on the
first request with `getSystemTimerManager: the TimerManager requires linking against
the threaded runtime`. `-with-rtsopts=-N` lets it use all available cores.
:::
:::info[Haskell builds are slow]
GHC compiles Scotty and its dependencies from source, so the first build takes several
minutes. Miren caches image layers, so rebuilds that don't change dependencies are much
faster.
:::
### .dockerignore
```text
.git
dist-newstyle
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "haskell-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `System.Environment.lookupEnv`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (native binary)
- **Build:** `cabal build` on the `haskell` image; copy `$(cabal list-bin )` to a slim image
- **Threaded runtime:** add `ghc-options: -threaded` — Warp/Scotty crashes on first request without it
- **Runtime libs:** `libgmp10 zlib1g` on `debian-slim`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** read `lookupEnv "PORT"`; Scotty/Warp binds all interfaces
- **Env vars:** `miren env set -e/-s`; read with `lookupEnv`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Language Guides
These guides take you from a project on your laptop to a running app on Miren, one
language at a time. Each one covers the same three things: **how to set up the app**,
**how to set environment variables**, and **whether its source build needs a Dockerfile**.
:::tip[Let your agent do this]
If you use an AI coding agent (Claude Code, Codex, Amp, and others), you don't have to
follow these guides by hand. Install the [Miren agent skills](../agent-skills.md) and ask
your agent to "set up this app on Miren" — it reads your project, detects the stack,
wires up environment variables, and deploys. These guides double as the reference your
agent works from. See [Agent Skills](../agent-skills.md) for setup.
:::
## Pick your language
| Guide | Auto-detected? | You provide |
|-------|----------------|-------------|
| [Python](./python.md) | Yes | `requirements.txt` / `pyproject.toml` / `Pipfile` / `uv.lock` |
| [JavaScript (Node & Bun)](./javascript.md) | Yes | `package.json` + a lockfile |
| [Go](./go.md) | Yes | `go.mod` |
| [Ruby](./ruby.md) | Yes | `Gemfile` |
| [Rust](./rust.md) | Yes | `Cargo.toml` |
| [Java / JVM](./java.md) | No | `Dockerfile.miren` |
| [PHP](./php.md) | No | `Dockerfile.miren` |
| [.NET / C#](./dotnet.md) | No | `Dockerfile.miren` |
| [C++](./cpp.md) | No | `Dockerfile.miren` |
| [C](./c.md) | No | `Dockerfile.miren` |
| [Deno](./deno.md) | No | `Dockerfile.miren` |
| [Elixir](./elixir.md) | No | `Dockerfile.miren` |
| [Kotlin](./kotlin.md) | No | `Dockerfile.miren` |
| [Swift](./swift.md) | No | `Dockerfile.miren` |
| [Dart](./dart.md) | No | `Dockerfile.miren` |
| [Scala](./scala.md) | No | `Dockerfile.miren` |
| [Clojure](./clojure.md) | No | `Dockerfile.miren` |
| [Erlang](./erlang.md) | No | `Dockerfile.miren` |
| [Haskell](./haskell.md) | No | `Dockerfile.miren` |
| [F#](./fsharp.md) | No | `Dockerfile.miren` |
| [Julia](./julia.md) | No | `Dockerfile.miren` |
| [R](./r.md) | No | `Dockerfile.miren` |
| [Lua](./lua.md) | No | `Dockerfile.miren` |
| [Perl](./perl.md) | No | `Dockerfile.miren` |
| [OCaml](./ocaml.md) | No | `Dockerfile.miren` |
| [Crystal](./crystal.md) | No | `Dockerfile.miren` |
| [Nim](./nim.md) | No | `Dockerfile.miren` |
| [Zig](./zig.md) | No | `Dockerfile.miren` |
| [Gleam](./gleam.md) | No | `Dockerfile.miren` |
| [Objective-C](./objc.md) | No | `Dockerfile.miren` |
| [Raku](./raku.md) | No | `Dockerfile.miren` |
| [Common Lisp](./commonlisp.md) | No | `Dockerfile.miren` |
| [JRuby](./jruby.md) | No | `Dockerfile.miren` |
| [TruffleRuby](./truffleruby.md) | No | `Dockerfile.miren` |
| [Klong (K)](./klong.md) | No | `Dockerfile.miren` |
| [COBOL](./cobol.md) | No | `Dockerfile.miren` |
| [Bash](./bash.md) | No | `Dockerfile.miren` |
| [Static sites & SPAs](./static.md) | No | `Dockerfile.miren` |
## Auto-detected vs. Dockerfile
Six stacks are auto-detected. Miren reads your project files, picks the build stack, and
builds a container image for you — **no Dockerfile required**. You run `miren init` once
and `miren deploy`, and Miren figures out the rest.
| Stack | Detected from | Default version |
|-------|---------------|-----------------|
| [Ruby](./ruby.md) | `Gemfile` | 3.2 |
| [Python](./python.md) | `requirements.txt`, `Pipfile`, `pyproject.toml`, or `uv.lock` | 3.11 |
| [Node.js](./javascript.md) | `package.json` + `package-lock.json`/`yarn.lock` | 20 |
| [Bun](./javascript.md) | `package.json` + `bun.lock` | 1 |
| [Go](./go.md) | `go.mod` | Parsed from `go.mod`, else 1.23 |
| [Rust](./rust.md) | `Cargo.toml` | 1.83 |
Each guide covers that stack's detection rules, build process, and start command in
full. Override any default version with `[build] version` in
[`.miren/app.toml`](../app-toml.md#build).
Every other language here — from Elixir and Gleam to Kotlin, Swift, Julia, and even
COBOL — isn't auto-detected, so its guide shows you a `Dockerfile.miren` you
can drop into your project. Miren builds from that Dockerfile instead of guessing. This
is the same escape hatch available to every language when you need full control over the
build.
:::tip[Want native support?]
Native builds cover the common stacks today (Python, Node, Bun, Go, Ruby, Rust). If you'd
like Miren to detect and build another language first-class — no Dockerfile needed —
[tell us what to build next](https://linear.miren.garden/suggest).
:::
## Already have a runnable image?
These language guides assume Miren starts with your source code. If you already publish a
runnable container image, skip both language detection and `Dockerfile.miren`: set
`services.web.image` and Miren launches the image directly.
Keep the image's defaults when they already start the right process. Add `args` when its
`ENTRYPOINT` is right but the deployment needs different arguments. Use `command` when you
need to replace the whole process with a shell command. Build a derived image only when the
deployment adds files or packages, or when the upstream image's startup contract is
incomplete. See [Deploying an existing image](../deployment.md#deploying-an-existing-image) for
the complete example.
## Using Dockerfile.miren {#using-dockerfilemiren}
For applications that require custom build steps or don't fit the automatic detection,
provide a `Dockerfile.miren` in your project root. Miren builds from it instead of
guessing your stack.
### When to use Dockerfile.miren
- Your application requires custom system dependencies
- You need a multi-stage build
- You're using a language that isn't auto-detected
- You need specific build-time configurations
### Example
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
```
### Deployment source priority
1. `build.dockerfile` setting in `app.toml` (if specified)
2. `Dockerfile.miren` in project root
3. `services.web.image` in `app.toml`
4. Automatic language detection
### Build arguments
Miren passes the following build arguments to your `Dockerfile.miren`:
- `MIREN_VERSION` — the app version this build is producing, the same identifier
[`miren app versions`](../command/app-versions.md) lists and the same value injected as the
`MIREN_VERSION` environment variable at runtime
As with any Docker build argument, declare it with `ARG` in the stage that uses it:
```dockerfile
FROM alpine:3.19
ARG MIREN_VERSION
RUN echo "building $MIREN_VERSION"
```
Build arguments apply to `Dockerfile.miren` builds only — auto-detected stacks don't run
a Dockerfile.
:::info[Custom Dockerfiles keep their startup defaults]
A `Dockerfile.miren` can run without an explicit service. Miren synthesizes a `web`
service that uses the image's `ENTRYPOINT` and `CMD`. Add a `Procfile` or
`[services.web]` block when you need to customize how the image starts. Set `web = false`
to suppress the synthesized service. An app with tasks but no services must set `web`
explicitly.
:::
## What every guide assumes
- You've installed Miren and can reach a cluster. If not, start with
[Getting Started](../getting-started.md).
:::warning[Bind to the injected port]
Your web service must bind to `0.0.0.0` on the port in the `PORT` environment variable.
Miren injects `PORT` at runtime and routes traffic to it — an app that hardcodes
`localhost` or a fixed port won't receive traffic.
:::
## Next steps
- [app.toml Reference — Build](../app-toml.md#build) — `version`, `dockerfile`, and `onbuild` settings
- [Deployment](../deployment.md) — how `miren deploy` builds and activates versions
- [App Configuration](../app-configuration.md) — customize with `.miren/app.toml`
- [Services](../services.md) — run workers and multiple processes
- [Agent Skills](../agent-skills.md) — let your agent operate Miren for you
---
## Java on Miren
The JVM isn't auto-detected, so you deploy Java apps with a `Dockerfile.miren` that
builds a runnable jar and runs it on a JRE image. This guide uses Spring Boot with
Maven as the example; the same pattern works for Gradle, Kotlin, Scala, and Clojure —
build a jar, then `java -jar` it.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Spring Boot app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds the server to
`0.0.0.0:$PORT`, wires up environment variables, and deploys — using this page as its
reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect the JVM, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it. Spring Boot reads `server.port`, so map
`PORT` to it in `src/main/resources/application.properties`:
```properties
server.port=${PORT:8080}
server.address=0.0.0.0
```
`${PORT:8080}` uses the `PORT` environment variable when present and falls back to 8080
for local development. Frameworks other than Spring bind however they normally do —
the key is to read `PORT` and listen on `0.0.0.0`.
## The Dockerfile
Create `Dockerfile.miren` in your project root. The build caches dependencies before
copying source so rebuilds are faster:
```dockerfile
# ----- Build stage -----
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -q -B dependency:go-offline
COPY src src
RUN mvn -q -B -DskipTests package
# ----- Runtime stage -----
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/target/app.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
```
Set `app ` in your `pom.xml` `` so the jar has a stable
name, or adjust the `COPY` to match your artifact.
### .dockerignore
```text
.git
target
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "java-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Spring maps env vars to properties (`SPRING_DATASOURCE_URL` →
`spring.datasource.url`):
```miren
miren env set -e SPRING_PROFILES_ACTIVE=prod
miren env set -s SPRING_DATASOURCE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "SPRING_PROFILES_ACTIVE"
value = "prod"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** `mvn -DskipTests package` (or Gradle); run the jar on a JRE image
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** Spring `server.port=${PORT:8080}` + `server.address=0.0.0.0`; other frameworks read `PORT` and bind `0.0.0.0`
- **Env vars:** `miren env set -e/-s`; Spring maps `SPRING_*` env vars to properties
- **Database:** optional `[addons.miren-postgresql]` injects `DATABASE_URL`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Addons](../addons.md) — managed Postgres and other backing services
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## JavaScript on Miren
Miren auto-detects both **Node.js** and **Bun** apps and builds a container image for
you — no Dockerfile required. It picks the right package manager from your lockfile
and runs the start command from your `package.json` or `Procfile`. TypeScript works
the same way; either build it during the image build or run it directly with Bun.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It detects Node vs. Bun, finds your start script,
wires up environment variables, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
No. Miren detects your project and builds the image automatically. Provide a
`Dockerfile.miren` only for custom build steps — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
## Node.js
**Detection:** `package.json` **and** a lockfile (`package-lock.json` or `yarn.lock`),
or a `Procfile` with a `web: node|npm|yarn` command. **Default version:** Node 20.
| Lockfile | Package manager | Install command |
|----------|-----------------|-----------------|
| `yarn.lock` | yarn | `yarn install` |
| `package-lock.json` | npm | `npm install` |
## Bun
**Detection:** `package.json` **and** `bun.lock`, or a `Procfile` with a `web: bun`
command. **Default version:** Bun 1. Bun can run TypeScript directly, so no separate
build step is needed for `.ts` entrypoints.
## Set up the app
From your project root:
```miren
miren init
miren deploy
```
Preview the detected stack, package manager, and start command without building:
```miren
miren deploy --analyze
```
### Start command
Your server must listen on `0.0.0.0` at the port in `$PORT` — Miren injects `PORT`
and routes traffic to it. Read it in code as `process.env.PORT` (Node) or
`Bun.env.PORT` / `process.env.PORT` (Bun). Miren runs your `package.json` start
script by default; a `Procfile` makes the command explicit:
```procfile
# Node — direct
web: node server.js
# Node — npm script
web: npm start
# Node — yarn script
web: yarn start
# Express — compiled output
web: node dist/index.js
# Next.js
web: npm run start
# Bun — run TypeScript directly
web: bun run src/index.ts
# Bun — run JavaScript
web: bun run src/index.js
# Bun — package.json script
web: bun run start
# Bun — Elysia
web: bun run src/server.ts
# Background worker (Node)
worker: node worker.js
# Background worker (Bun)
worker: bun run worker.ts
```
An Express server, for example, must bind the injected port:
```js
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));
```
### Building TypeScript / assets
For Node projects that compile TypeScript or bundle assets, run the build during the
image build with an `onbuild` step in `.miren/app.toml`:
```toml
[build]
onbuild = ["npm run build"]
```
Then point your start command at the compiled output (e.g. `web: node dist/index.js`).
Bun apps can skip this and run `.ts` files directly.
See [Services](../services.md) to run a worker alongside your web process.
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked
in output and logs):
```miren
miren env set -e NODE_ENV=production
miren env set -s DATABASE_URL
miren env set -s SESSION_SECRET
```
`miren env set -s SESSION_SECRET` (no value) prompts with masked input. You can also
declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** `package.json` + lockfile — `bun.lock` → Bun, `yarn.lock`/`package-lock.json` → Node. A `Procfile` with a `web: bun` or `web: node|npm|yarn` command also works in place of a lockfile
- **Default versions:** Node 20, Bun 1 (override via `[build] version` in `.miren/app.toml`)
- **Install:** `yarn install` / `npm install` / `bun install` by lockfile
- **Start command:** listen on `0.0.0.0:$PORT` via `process.env.PORT`; runs `package.json` start by default, or set a `Procfile`
- **TypeScript:** Node → build with `[build] onbuild`; Bun → runs `.ts` directly
- **Env vars:** `miren env set -e KEY=VALUE`, `-s` for secrets, or `[[env]]` in `app.toml`
- **Dockerfile:** not needed; add `Dockerfile.miren` only for custom builds
## Next steps
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Services](../services.md) — web + workers
- [Deployment](../deployment.md) — how deploys build and activate
---
## JRuby on Miren
JRuby runs Ruby on the JVM. Miren auto-detects standard (MRI) Ruby from a `Gemfile` —
see [Ruby on Miren](./ruby.md) — but to run on JRuby specifically you use a
`Dockerfile.miren`. Your Ruby code and gems run unchanged; they execute on the JVM.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this app on JRuby on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, wires up Bundler and
the server, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes — to run on JRuby specifically. (Miren's auto-detection would pick MRI Ruby.) Add a
`Dockerfile.miren` built on the `jruby` image. See
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The app
A normal Sinatra app with a `Gemfile` — nothing JRuby-specific:
```ruby
# Gemfile
source 'https://rubygems.org'
gem 'sinatra'
gem 'puma'
gem 'rackup'
```
```ruby
# app.rb
require 'sinatra/base'
class App < Sinatra::Base
# Sinatra 4 rejects unknown Host headers; an empty list permits any host.
set :host_authorization, permitted_hosts: []
get '/' do
content_type 'text/plain'
"Hello from JRuby on Miren!\n"
end
end
```
:::warning[Sinatra 4 blocks the deploy host]
Sinatra 4 enables `Rack::Protection::HostAuthorization`, which only allows requests whose
`Host` header is on an allowlist (localhost by default). Behind Miren's router your app is
reached at its route hostname, so without configuration it returns `Host not permitted`.
Set `host_authorization` to an empty `permitted_hosts` list (allow any), or list your
actual hostnames. This applies to any Sinatra 4 app, MRI included.
:::
```ruby
# config.ru
require './app'
run App
```
Miren injects `PORT` and routes to it; the Dockerfile's Puma `CMD` below binds
`0.0.0.0` on that port.
## The Dockerfile
Create `Dockerfile.miren` in your project root. Install gems with Bundler at build time:
```dockerfile
FROM jruby:9.4
WORKDIR /app
COPY Gemfile ./
RUN bundle install
COPY . /app
EXPOSE 8080
CMD ["sh", "-c", "exec bundle exec puma -b tcp://0.0.0.0:$PORT"]
```
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
JRuby's JVM startup plus Bundler and Puma can take longer than the default 15-second
port-wait, so raise `port_timeout`. Keeping one instance always warm (fixed scaling) also
avoids paying that cold-start on every scale-from-zero. Set both in `.miren/app.toml`:
```toml
name = "jruby-bench"
[services.web]
# JVM Ruby boots slowly; give it more than the 15s default to bind.
port_timeout = "120s"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
```
```miren
miren deploy
```
:::warning[JVM boot vs. the port timeout]
By default Miren waits 15 seconds for a service to bind its port, then reports
`nothing is listening after the port timeout`. JRuby's JVM startup plus Bundler and Puma
routinely exceeds that. Raise `port_timeout` (e.g. `"120s"`) so the health check waits
long enough, and use fixed scaling so a warm instance stays up.
:::
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `ENV['KEY']`:
```miren
miren env set -e RACK_ENV=production
miren env set -s DATABASE_URL
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** MRI Ruby is auto-detected from a `Gemfile`; use `Dockerfile.miren` to pin JRuby
- **Base image:** `jruby:9.4`; `bundle install` at build time
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Slow boot:** raise `port_timeout` (e.g. `"120s"`) past the 15s default, and pin `num_instances = 1` to stay warm
- **Port:** Puma `-b tcp://0.0.0.0:$PORT`
- **Env vars:** `miren env set -e/-s`; read with `ENV['KEY']`
## Next steps
- [Ruby on Miren](./ruby.md) — auto-detected MRI Ruby
- [TruffleRuby on Miren](./truffleruby.md) — Ruby on GraalVM
- [Application Scaling](../scaling.md) — fixed vs. autoscaling
---
## Julia on Miren
Julia isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[HTTP.jl](https://github.com/JuliaWeb/HTTP.jl) directly; the same pattern works for
Genie or Oxygen.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Julia app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Julia, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`:
```julia
using HTTP
port = parse(Int, get(ENV, "PORT", "8080"))
println("listening on 0.0.0.0:$port")
HTTP.serve("0.0.0.0", port) do req
HTTP.Response(200, "Hello from Julia on Miren!\n")
end
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. Install and precompile packages during
the build so startup is fast:
```dockerfile
FROM julia:1.10
WORKDIR /app
RUN julia -e 'using Pkg; Pkg.add("HTTP"); using HTTP'
COPY . /app
EXPOSE 8080
CMD ["julia", "/app/app.jl"]
```
:::info[Cold-start compile]
Julia JIT-compiles on first use, so the first request after an instance starts is
slower than later ones. Precompiling in the build (`using HTTP`) reduces this. For
heavier apps, consider `PackageCompiler.jl` to bake a sysimage.
:::
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "julia-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `ENV["KEY"]` or `get(ENV, "KEY", default)`:
```miren
miren env set -e JULIA_NUM_THREADS=4
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `julia:1.10`; `Pkg.add` + `using` in the build to precompile
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `get(ENV, "PORT", "8080")`; `HTTP.serve("0.0.0.0", port)`
- **Cold start:** JIT compiles on first request; precompile in build or use `PackageCompiler.jl`
- **Env vars:** `miren env set -e/-s`; read with `ENV["KEY"]`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Klong (K) on Miren
[Klong](https://t3x.org/klong/) is an open-source array language in the K/APL family.
Array languages don't ship HTTP servers, so — as with [COBOL](./cobol.md) and
[Bash](./bash.md) — this guide has the Klong program print an HTTP response and puts
`socat` in front of it to own the socket. It uses [KlongPy](https://klongpy.org), a
pip-installable Klong implementation.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Klong app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren` and the socket
front-end, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead of
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The program
`.d` writes a string to standard output verbatim. Put a complete HTTP response —
status line, headers, a blank line, then the body — in `hello.kg`:
```klong
.d("HTTP/1.1 200 OK
Content-Type: text/plain
Connection: close
Hello from Klong on Miren!
")
```
:::note[Run Klong from a file for clean output]
Run the program as a **file** (`kgpy hello.kg`), not with `-e` or over a pipe — those
start the REPL and echo the expression result (`"…"` with quotes) alongside your
output. In file mode only your `.d` bytes are written.
:::
## The socket front-end
The program doesn't bind a port — `socat` does. Miren injects `PORT`, and `socat`'s
`TCP-LISTEN` binds all interfaces; `fork` runs Klong once per connection. The Dockerfile's
`CMD` starts that socket front-end.
:::note[Behind Miren's ingress]
Miren's HTTP ingress terminates TLS and handles the public HTTP layer in front of your
app, so `socat` only needs to hand each accepted connection to your program — it isn't
exposed to raw internet traffic. The one practical limit is that `fork` spawns a process
per request, so this suits low-traffic endpoints and tooling rather than high-throughput
services.
:::
## The Dockerfile
Create `Dockerfile.miren` in your project root. KlongPy installs from PyPI (it also needs
`colorama` for its CLI), and `socat` comes from apt:
```dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir klongpy colorama \
&& apt-get update -y && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY hello.kg /app/
EXPOSE 8080
CMD ["sh", "-c", "exec socat TCP-LISTEN:$PORT,reuseaddr,fork EXEC:'kgpy /app/hello.kg'"]
```
### .dockerignore
```text
.git
```
## Deploy
Miren uses the image's `CMD` and infers the web port from `EXPOSE 8080`, so you don't
need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "klong-bench"
```
```miren
miren deploy
```
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Runtime:** KlongPy (`pip install klongpy colorama`) on `python:3.12-slim`
- **Serving:** the Klong program `.d`-prints a full HTTP response; `socat` owns the socket
- **Run from a file:** `kgpy hello.kg` (file mode) — `-e`/pipe modes echo the result too
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `socat TCP-LISTEN:$PORT` binds `0.0.0.0`
## Next steps
- [COBOL on Miren](./cobol.md) and [Bash on Miren](./bash.md) — the same `socat` pattern
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Deployment](../deployment.md) — how deploys build and activate
---
## Kotlin on Miren
Kotlin isn't auto-detected, so you deploy it with a `Dockerfile.miren` that builds a fat
jar and runs it on a JRE. This guide uses [Ktor](https://ktor.io) with its embedded
Netty server.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Ktor app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds the server to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect the JVM, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`:
```kotlin
fun main() {
val port = System.getenv("PORT")?.toIntOrNull() ?: 8080
embeddedServer(Netty, port = port, host = "0.0.0.0") {
routing {
get("/") { call.respondText("Hello from Kotlin on Miren!\n") }
}
}.start(wait = true)
}
```
A `build.gradle.kts` using the Shadow plugin to produce a fat jar:
```kotlin
plugins {
kotlin("jvm") version "2.0.20"
application
id("com.gradleup.shadow") version "8.3.5"
}
repositories { mavenCentral() }
dependencies {
implementation("io.ktor:ktor-server-netty:2.3.12")
}
application { mainClass.set("MainKt") }
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. The build runs `shadowJar` and the
runtime image copies the resulting `*-all.jar`:
```dockerfile
# ----- Build stage -----
FROM gradle:8.10-jdk21 AS builder
WORKDIR /app
COPY . .
RUN gradle shadowJar --no-daemon
# ----- Runtime stage -----
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/build/libs/*-all.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
```
### .dockerignore
```text
.git
build
.gradle
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "kotlin-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `System.getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** `gradle shadowJar` (fat jar) on `gradle:8.10-jdk21`; run on a JRE image
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `System.getenv("PORT")`; `embeddedServer(Netty, port, host = "0.0.0.0")`
- **Env vars:** `miren env set -e/-s`; read with `System.getenv`
## Next steps
- [Java on Miren](./java.md) — the JVM sibling guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Lua on Miren
Lua isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[lua-http](https://github.com/daurnimator/lua-http) — a proper Lua HTTP server —
installed with LuaRocks. (For a heavier stack, OpenResty (nginx + Lua) works too.)
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Lua app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds the server to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Lua, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`. With
lua-http:
```lua
local server = require "http.server"
local headers = require "http.headers"
local port = tonumber(os.getenv("PORT") or "8080")
local function on_stream(_, stream)
stream:get_headers()
local res = headers.new()
res:append(":status", "200")
res:append("content-type", "text/plain")
stream:write_headers(res, false)
stream:write_chunk("Hello from Lua on Miren!\n", true)
end
local s = assert(server.listen {
host = "0.0.0.0",
port = port,
onstream = on_stream,
})
assert(s:listen())
print("listening on 0.0.0.0:" .. port)
assert(s:loop())
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. LuaRocks builds lua-http and its C
dependencies (cqueues, luaossl), so the image needs a compiler and OpenSSL headers:
```dockerfile
FROM debian:12-slim
RUN apt-get update -y \
&& apt-get install -y lua5.4 liblua5.4-dev luarocks gcc make m4 libssl-dev git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN luarocks --lua-version=5.4 install http
COPY . /app
EXPOSE 8080
CMD ["lua5.4", "/app/app.lua"]
```
:::note[Pin the Lua version for LuaRocks]
Debian's `luarocks` can target several Lua versions. Pass `--lua-version=5.4` (matching
the `lua5.4`/`liblua5.4-dev` packages) so the rock and its C parts build against the
same interpreter you run with.
:::
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "lua-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `os.getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s API_TOKEN
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Library:** lua-http via `luarocks --lua-version=5.4 install http` (needs `gcc make m4 libssl-dev`)
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `os.getenv("PORT")`; `server.listen { host = "0.0.0.0", port = port }`
- **Env vars:** `miren env set -e/-s`; read with `os.getenv`
- **Heavier option:** OpenResty (nginx + Lua) for a production Lua web stack
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Nim on Miren
Nim isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles a
native binary and runs it on a minimal image. This guide uses the
[Jester](https://github.com/dom96/jester) web framework.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Nim app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Nim, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`. With
Jester:
```nim
let appPort = parseInt(getEnv("PORT", "8080"))
settings:
port = Port(appPort)
bindAddr = "0.0.0.0"
routes:
get "/":
resp "Hello from Nim on Miren!\n"
```
A minimal `.nimble` file declares the dependency and binary:
```nim
version = "0.1.0"
author = "you"
description = "nim on miren"
license = "MIT"
srcDir = "."
bin = @["app"]
requires "nim >= 2.0.0"
requires "jester >= 0.6.0"
```
## The Dockerfile
Create `Dockerfile.miren` in your project root:
```dockerfile
# ----- Build stage -----
FROM nimlang/nim:2.0.8 AS builder
WORKDIR /app
COPY . .
RUN nimble install -dy && nim c -d:release --opt:speed --mm:refc -o:app app.nim
# ----- Runtime stage -----
FROM debian:12-slim
RUN apt-get update -y && apt-get install -y libpcre3 openssl ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/app /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
:::warning[Compile Jester with `--mm:refc`]
Jester runs on httpbeast, which spawns worker threads. Built with Nim 2.0's default ORC
memory manager, the app **segfaults on startup** (`SIGSEGV: Illegal storage access`,
right after "Starting N threads"). Compiling with `--mm:refc` (the older reference-counting
GC) fixes it. This guide also uses a glibc base (`debian`, via the non-Alpine `nimlang/nim`
image) and installs `libpcre3` for Jester's routing.
:::
### .dockerignore
```text
.git
app
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "nim-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `getEnv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (native binary)
- **Build:** `nimble install -dy && nim c -d:release --mm:refc -o:app app.nim`
- **`--mm:refc` required:** Jester/httpbeast segfaults on startup with Nim 2.0's default ORC GC
- **Runtime libs:** `libpcre3 openssl` on `debian-slim` (glibc base)
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `getEnv("PORT", "8080")`; Jester `settings: bindAddr = "0.0.0.0"`
- **Env vars:** `miren env set -e/-s`; read with `getEnv`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Objective-C on Miren
Objective-C isn't auto-detected, so you deploy it with a `Dockerfile.miren` that
compiles your app with [GNUstep](https://www.gnustep.org). This guide uses
[SOPE](https://github.com/inverse-inc/sope) (the SKYRiX Object Publishing Environment —
the Objective-C web framework that SOGo is built on), whose `WOApplication` and
`WOHttpAdaptor` give you a real HTTP server, WebObjects-style.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Objective-C app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren` and GNUstep build,
and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead of
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The app
Subclass `WOApplication` and override `dispatchRequest:` to return a `WOResponse`.
`WOApplicationMain` starts the app and its built-in HTTP adaptor:
```objc
#import
#import
#import
#import
@interface HelloApp : WOApplication
@end
@implementation HelloApp
- (WOResponse *)dispatchRequest:(WORequest *)_request {
WOResponse *r = [WOResponse responseWithRequest:_request];
[r setStatus:200];
[r setHeader:@"text/plain" forKey:@"content-type"];
[r appendContentString:@"Hello from Objective-C on Miren!\n"];
return r;
}
@end
int main(int argc, const char *argv[]) {
return WOApplicationMain(@"HelloApp", argc, argv);
}
```
## Bind to the injected port
SOPE's HTTP adaptor takes its listen address from the `WOPort` default, which you pass
on the command line. Miren injects `PORT`, so start the app with
`-WOPort 0.0.0.0:$PORT`. The Dockerfile's `CMD` below supplies that command.
:::warning[Give WOPort an explicit `0.0.0.0`]
`-WOPort 8080` alone makes the adaptor bind the wildcard address (`*:8080`), which fails
on this stack with `NGCouldNotBindSocketException … Address family not supported`. Pass
the address explicitly — `-WOPort 0.0.0.0:$PORT` — so it binds IPv4 on all interfaces.
The `sh -c '. GNUstep.sh && exec …'` wrapper sources the GNUstep environment so the app
finds its frameworks at runtime.
:::
## The Dockerfile
Build with GNUstep's makefile system. `gnustep-make` provides only the makefiles, so
install GNU `make` too; `libsope-dev` provides SOPE's headers and libraries. A
single-stage image keeps the GNUstep runtime and SOPE libraries available at run time:
```dockerfile
FROM debian:12
RUN apt-get update -y \
&& apt-get install -y make gobjc gnustep-make gnustep-base-runtime libgnustep-base-dev libsope-dev libsope1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY main.m GNUmakefile ./
RUN . /usr/share/GNUstep/Makefiles/GNUstep.sh && make
EXPOSE 8080
CMD ["sh", "-c", ". /usr/share/GNUstep/Makefiles/GNUstep.sh && exec /app/obj/app -WOPort 0.0.0.0:$PORT"]
```
The `GNUmakefile` builds a plain tool linked against the SOPE libraries (SOPE's own
`woapp.make` bundle fragment is too old for current gnustep-make, so link them directly):
```makefile
include $(GNUSTEP_MAKEFILES)/common.make
TOOL_NAME = app
app_OBJC_FILES = main.m
app_TOOL_LIBS += -lNGObjWeb -lNGExtensions -lEOControl -lNGStreams -lNGMime -lSaxObjC -lDOM
include $(GNUSTEP_MAKEFILES)/tool.make
```
The compiled binary lands at `obj/app`.
### .dockerignore
```text
.git
```
## Deploy
Miren uses the image's `CMD` and infers the web port from `EXPOSE 8080`, so you don't
need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "objc-bench"
```
```miren
miren deploy
```
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Framework:** SOPE (`libsope-dev`) — `WOApplication` + `WOHttpAdaptor`; override `dispatchRequest:`
- **Build:** GNUstep makefiles as a plain tool linking `-lNGObjWeb …` (install `make`); binary at `obj/app`
- **Port:** `-WOPort 0.0.0.0:$PORT` — the explicit `0.0.0.0` avoids a wildcard bind failure
- **Runtime:** source `GNUstep.sh` before exec so the app finds its frameworks
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
## Next steps
- [C on Miren](./c.md) — the C guide (Objective-C is a superset of C)
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Deployment](../deployment.md) — how deploys build and activate
---
## OCaml on Miren
OCaml isn't auto-detected, so you deploy it with a `Dockerfile.miren` that builds a
native binary with opam and dune and runs it on a minimal image. This guide uses the
[Dream](https://aantron.github.io/dream/) web framework as the example.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this OCaml app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms the server
binds `0.0.0.0:$PORT`, wires up environment variables, and deploys — using this page as
its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect OCaml, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so your server must read `PORT` and
listen on `0.0.0.0`. With Dream:
```ocaml
let () =
let port =
match Sys.getenv_opt "PORT" with
| Some p -> int_of_string p
| None -> 8080
in
Dream.run ~interface:"0.0.0.0" ~port
@@ Dream.logger
@@ Dream.router [
Dream.get "/" (fun _ -> Dream.respond "Hello from OCaml on Miren!\n");
]
```
`~interface:"0.0.0.0"` is required — Dream binds to `localhost` by default, which Miren
can't route to.
## The Dockerfile
Create `Dockerfile.miren` in your project root:
```dockerfile
# ----- Build stage -----
FROM ocaml/opam:debian-12-ocaml-5.2 AS builder
# Install system libraries Dream needs. Miren's build sandbox runs without
# new privileges, so `sudo` fails here — switch to root instead.
USER root
RUN apt-get update -y \
&& apt-get install -y libev-dev libgmp-dev libssl-dev pkg-config m4
USER opam
WORKDIR /app
COPY --chown=opam:opam . .
# System deps are already installed above, so skip opam's sudo-based depext step.
RUN opam install -y --no-depexts dream dune
RUN opam exec -- dune build --profile release ./main.exe
# ----- Runtime stage -----
FROM debian:12-slim
RUN apt-get update -y \
&& apt-get install -y libev4 libgmp10 libssl3 ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/_build/default/main.exe /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
:::warning[Use `USER root`, not `sudo`, for system packages]
The `ocaml/opam` image runs as the `opam` user and expects `sudo` for `apt-get`. Miren's
build sandbox runs with no new privileges, so `sudo` fails with an exit code. Switch to
`USER root` to install packages, switch back to `USER opam`, and pass `--no-depexts` to
`opam install` so it doesn't try to `sudo apt-get` the same libraries again.
:::
A minimal `dune-project` and `dune`:
```lisp
; dune-project
(lang dune 3.0)
```
```lisp
; dune
(executable
(name main)
(libraries dream))
```
### .dockerignore
```text
.git
_build
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "ocaml-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `Sys.getenv_opt "KEY"`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (native binary)
- **Build:** `opam install --no-depexts dream dune`, `dune build --profile release ./main.exe`
- **System deps:** use `USER root` for `apt-get` (`sudo` fails in the build sandbox); runtime needs `libev4 libgmp10 libssl3`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** read `Sys.getenv_opt "PORT"`; Dream needs `~interface:"0.0.0.0"`
- **Env vars:** `miren env set -e/-s`; read with `Sys.getenv_opt`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Perl on Miren
Perl isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[Mojolicious](https://mojolicious.org), which ships its own production web server — no
separate PSGI wiring needed. The same pattern works for Dancer2 or any PSGI app run
under Plack.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Perl app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, points the server
at `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Perl, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it. Mojolicious's `daemon` command takes a
listen URL — use `http://*:$PORT` to bind all interfaces:
```perl
use Mojolicious::Lite -signatures;
get '/' => sub ($c) {
$c->render(text => "Hello from Perl on Miren!\n");
};
app->start;
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. Install dependencies with `cpanm`:
```dockerfile
FROM perl:5.40
WORKDIR /app
RUN cpanm --notest Mojolicious
COPY . /app
EXPOSE 8080
CMD ["sh", "-c", "exec perl /app/app.pl daemon -l \"http://*:$PORT\""]
```
For an app with a `cpanfile`, install from it instead: `RUN cpanm --notest --installdeps .`
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "perl-bench"
```
```miren
miren deploy
```
For a PSGI app (Dancer2, Catalyst), install Plack and Starman in the Dockerfile:
```dockerfile
RUN cpanm --notest Plack Starman
```
Then replace the Dockerfile's `CMD` with one that starts the PSGI app:
```dockerfile
CMD ["sh", "-c", "exec plackup -s Starman --host 0.0.0.0 --port \"$PORT\" /app/app.psgi"]
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `$ENV{KEY}`:
```miren
miren env set -e MOJO_MODE=production
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `perl:5.40`; `cpanm --notest Mojolicious` (or `--installdeps .` with a `cpanfile`)
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** Mojolicious `daemon -l http://*:$PORT`; PSGI apps use `plackup --host 0.0.0.0 --port $PORT`
- **Env vars:** `miren env set -e/-s`; read with `$ENV{KEY}`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## PHP on Miren
PHP isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[FrankenPHP](https://frankenphp.dev) — a modern PHP application server that runs your
app from a single image, no separate nginx + php-fpm wiring. It works for plain PHP and
for frameworks like Laravel and Symfony.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Laravel app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, points the server
at `0.0.0.0:$PORT`, wires up environment variables, and deploys — using this page as its
reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect PHP, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The Dockerfile
Create `Dockerfile.miren` in your project root. FrankenPHP serves your `public/`
directory:
```dockerfile
FROM dunglas/frankenphp:1-php8.3
WORKDIR /app
COPY . /app
EXPOSE 8080
CMD ["sh", "-c", "exec frankenphp php-server --listen 0.0.0.0:$PORT --root /app/public"]
```
For a Laravel or Composer app, install dependencies during the build:
```dockerfile
FROM dunglas/frankenphp:1-php8.3
# Composer from its official pinned image — no piping a remote installer into php
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN install-php-extensions pdo_pgsql pdo_mysql zip
WORKDIR /app
# Copy the manifests first so the install layer is deterministic and cache-friendly;
# committing composer.lock pins exact dependency versions.
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
COPY . /app
RUN composer dump-autoload --optimize --no-interaction
EXPOSE 8080
CMD ["sh", "-c", "exec frankenphp php-server --listen 0.0.0.0:$PORT --root /app/public"]
```
(`install-php-extensions` ships with the FrankenPHP image.)
### .dockerignore
```text
.git
vendor
```
## Deploy
The Dockerfile's `CMD` starts the app, so you don't need a `Procfile` or service command.
The FrankenPHP base image exposes several HTTP, HTTPS, and admin ports; select this app's
HTTP port explicitly.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "php-bench"
[services.web]
port = 8080
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `getenv()` or your framework's config (Laravel's
`env()`):
```miren
miren env set -e APP_ENV=production
miren env set -s APP_KEY
miren env set -s DATABASE_URL
```
Generate a Laravel `APP_KEY` locally with `php artisan key:generate --show`. You can
also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "APP_ENV"
value = "production"
```
Need a managed Postgres database? Add a [`miren-postgresql` addon](../addons.md) and Miren
injects `DATABASE_URL` for you. See
[App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `dunglas/frankenphp:1-php8.3`; use `install-php-extensions` for pdo_pgsql, etc.
- **Composer:** `composer install --no-dev --optimize-autoloader` during the build
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** FrankenPHP `--listen 0.0.0.0:$PORT`
- **Env vars:** `miren env set -e/-s`; read with `getenv()` / Laravel `env()`
- **Database:** optional `[addons.miren-postgresql]` injects `DATABASE_URL`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Addons](../addons.md) — managed Postgres and other backing services
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Python on Miren
Miren auto-detects Python apps and builds a container image for you — no Dockerfile
required. It recognizes pip, Pipenv, Poetry, and uv, and configures a start command
for common web frameworks.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Python app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It detects your framework and package manager,
proposes a start command, wires up environment variables, and deploys — using this
page as its reference.
:::
## Does this source build need a Dockerfile?
No. Miren detects Python from a `requirements.txt`, `Pipfile`, `pyproject.toml`, or
`uv.lock` and builds the image automatically. The default Python version is **3.11**;
override it in [`.miren/app.toml`](../app-configuration.md) if you need another.
Provide a `Dockerfile.miren` only if your build needs custom system packages or steps
that don't fit detection — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
## Set up the app
From your project root, initialize and deploy:
```miren
miren init
miren deploy
```
`miren init` scaffolds `.miren/app.toml` and scans your project for the environment
variables it needs. `miren deploy` uploads your code, builds the image, and activates
the new version. Preview what Miren detects — stack, package manager, and start
command — without building:
```miren
miren deploy --analyze
```
### Package managers
Miren supports four Python dependency management systems and picks the install command
from the files in your repo. The table is a **priority order**: if your project has
more than one of these files, the first match wins.
| File | Package manager | Install command |
|------|-----------------|-----------------|
| `Pipfile` | pipenv | `pipenv install --deploy` |
| `uv.lock` | uv | `uv sync --frozen` |
| `pyproject.toml` | poetry | `poetry install --no-root` |
| `requirements.txt` | pip | `pip install -r requirements.txt` |
So a project with both a `Pipfile` and a `requirements.txt` builds with pipenv, not pip.
### Start command
Miren picks the start command from the **server package** in your dependencies rather
than from the web framework itself. The first rule that matches wins. You can always
override it with a `Procfile` or the `command` field in `.miren/app.toml`.
| Rule | Condition | Start command |
|------|-----------|---------------|
| 1 | `gunicorn` present, and no `fastapi` | `gunicorn -b 0.0.0.0:$PORT` |
| 2 | `fastapi` present | `fastapi run --host 0.0.0.0 --port $PORT` |
| 3 | `uvicorn` present | `uvicorn --host 0.0.0.0 --port $PORT` |
| 4 | `flask` present, no `gunicorn` | `flask run --host=0.0.0.0 --port=$PORT` |
| 5 | `django` and `manage.py` present, no `gunicorn` | `python manage.py runserver 0.0.0.0:$PORT` |
So a Django app is served by gunicorn when gunicorn is in its dependencies — Django
itself isn't what decides. FastAPI takes precedence over gunicorn, because `fastapi run`
is the framework's recommended entrypoint.
Miren fills in the module path from your project layout:
- **WSGI:** a `wsgi.py` in a subdirectory gives `.wsgi:application` (the Django
layout); a `wsgi.py` at the root gives `wsgi:app`; otherwise it falls back to `app:app`.
- **ASGI:** the same search over `asgi.py` gives `.asgi:application` or
`asgi:app`, falling back to `main:app`.
- **FastAPI:** the entrypoint from `[tool.fastapi]` in `pyproject.toml`, else `main.py`
or `app.py`.
:::warning[Add a production server]
Rules 4 and 5 fall back to the Flask and Django development servers. Those are meant for
local use, not production traffic. Add `gunicorn` (or `uvicorn` for an ASGI app) to your
dependencies, or set an explicit `web:` line in a `Procfile`.
:::
Your server must bind to `0.0.0.0` on `$PORT` — Miren injects `PORT` and routes
traffic to it. A `Procfile` makes this explicit:
```procfile
# gunicorn (Flask / Django / WSGI)
web: gunicorn app:app --bind 0.0.0.0:$PORT
# Celery background worker
worker: celery -A tasks worker --loglevel=info
```
Pick a single `web:` line for your app. For an ASGI app, use uvicorn instead:
```procfile
web: uvicorn main:app --host 0.0.0.0 --port $PORT
```
With uv, pipenv, or poetry, prefix the command with the package manager:
```procfile
# uv
web: uv run gunicorn app:app --bind 0.0.0.0:$PORT
# Pipenv
web: pipenv run gunicorn app:app --bind 0.0.0.0:$PORT
# Poetry
web: poetry run gunicorn app:app --bind 0.0.0.0:$PORT
```
FastAPI is auto-detected, so a `Procfile` is optional — but you can be explicit:
```procfile
web: fastapi run
```
See [Services](../services.md) for running a worker alongside your web process.
## Environment variables
Set variables with `miren env set`. Use `-e` for plain values and `-s` for secrets
(masked in output and logs):
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
miren env set -s SECRET_KEY
```
`miren env set -s SECRET_KEY` (no value) prompts with masked input. You can also
declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** `requirements.txt`, `Pipfile`, `pyproject.toml`, or `uv.lock`
- **Default version:** Python 3.11 (override via `[build] version` in `.miren/app.toml`)
- **Install:** pipenv / uv / poetry / pip, chosen by manifest in that priority order (see table above)
- **Start command:** chosen by server package, first match wins — gunicorn (unless FastAPI) → `fastapi run` → uvicorn → `flask run` → `manage.py runserver`; bind `0.0.0.0:$PORT`, or set a `Procfile`
- **Production server:** without gunicorn/uvicorn, Flask and Django fall back to their dev servers
- **Env vars:** `miren env set -e KEY=VALUE`, `-s` for secrets, or `[[env]]` in `app.toml`
- **Dockerfile:** not needed; add `Dockerfile.miren` only for custom builds
## Next steps
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Services](../services.md) — web + workers
- [Deployment](../deployment.md) — how deploys build and activate
---
## R on Miren
R isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[Plumber](https://www.rplumber.io) to turn R functions into an HTTP API — a common way
to serve models and data-science code.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this R API on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds Plumber to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect R, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it. Define endpoints in a Plumber file and
run it on `0.0.0.0` at the injected port.
`plumber.R`:
```r
#* @get /
#* @serializer text
function() {
"Hello from R on Miren!\n"
}
```
`entrypoint.R`:
```r
library(plumber)
port <- as.integer(Sys.getenv("PORT", "8080"))
pr("plumber.R") |> pr_run(host = "0.0.0.0", port = port)
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. The `rstudio/plumber` image already has
R and Plumber installed:
```dockerfile
FROM rstudio/plumber:latest
WORKDIR /app
COPY . /app
EXPOSE 8080
ENTRYPOINT ["Rscript"]
CMD ["/app/entrypoint.R"]
```
If you need extra packages, install them in the build:
`RUN R -e 'install.packages(c("DBI", "RPostgres"))'`.
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `ENTRYPOINT` and `CMD` start the app. Miren uses them as the web
service's startup default, so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "r-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `Sys.getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `rstudio/plumber:latest` (R + Plumber preinstalled)
- **Startup:** inherited from the Dockerfile `ENTRYPOINT` and `CMD`; no `Procfile` or service command needed
- **Port:** `Sys.getenv("PORT")`; `pr_run(host = "0.0.0.0", port = port)`
- **Env vars:** `miren env set -e/-s`; read with `Sys.getenv`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Raku on Miren
Raku isn't auto-detected, so you deploy it with a `Dockerfile.miren`. This guide uses
[Cro](https://cro.raku.org), the standard Raku framework for building HTTP services.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Raku app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds the server to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Raku, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`. The
final `sleep;` keeps the process alive after the server starts in the background:
```raku
use Cro::HTTP::Server;
use Cro::HTTP::Router;
my $application = route {
get -> {
content 'text/plain', "Hello from Raku on Miren!\n";
}
}
my $port = %*ENV // 8080;
my Cro::Service $service = Cro::HTTP::Server.new(
:host('0.0.0.0'), :port(+$port), :$application,
);
$service.start;
say "listening on 0.0.0.0:$port";
sleep;
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. The `rakudo-star` image bundles Rakudo
and the `zef` package manager; Cro's TLS support links against OpenSSL, so install
`libssl-dev` before `zef install`:
```dockerfile
FROM rakudo-star:latest
RUN apt-get update -y && apt-get install -y libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN zef install --/test Cro::HTTP
COPY . /app
EXPOSE 8080
CMD ["raku", "/app/app.raku"]
```
:::warning[Cro needs the OpenSSL headers]
Without `libssl-dev`, installing `Cro::HTTP` fails while compiling `Cro::TLS` with
`Cannot locate native library 'libssl.so'`. The dev package provides the `libssl.so`
symlink Cro's native bindings look for.
:::
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "raku-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `%*ENV`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Base image:** `rakudo-star:latest` (Rakudo + zef); `zef install --/test Cro::HTTP`
- **OpenSSL:** install `libssl-dev` before `zef install` or Cro::TLS fails to build
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Keep-alive:** end with `sleep;` so the process stays up after `$service.start`
- **Port:** `%*ENV`; `Cro::HTTP::Server.new(:host('0.0.0.0'), :port(...))`
- **Env vars:** `miren env set -e/-s`; read with `%*ENV`
## Next steps
- [Perl on Miren](./perl.md) — the Perl guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [Deployment](../deployment.md) — how deploys build and activate
---
## Ruby on Miren
Miren auto-detects Ruby apps from a `Gemfile`, installs gems with Bundler, and
configures the right web server — no Dockerfile required. Rails, Puma, and Rack apps
all work out of the box.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Rails app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It detects your framework, stages secrets like
`SECRET_KEY_BASE` and `RAILS_MASTER_KEY`, proposes a start command, and deploys —
using this page as its reference.
:::
## Does this source build need a Dockerfile?
No. Miren detects Ruby from your `Gemfile` and builds the image automatically. The
default Ruby version is **3.4**; override it in [`.miren/app.toml`](../app-configuration.md)
if you need another. Provide a `Dockerfile.miren` only for custom build steps — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
## Set up the app
From your project root:
```miren
miren init
miren deploy
```
Preview what Miren detects — framework, entrypoint, staged env vars — without building:
```miren
miren deploy --analyze
```
### Build process
Miren installs system dependencies (`build-essential`, `libpq-dev`, `nodejs`,
`libyaml-dev`, `postgresql-client`), then runs `bundle install` with
`BUNDLE_WITHOUT=development`. If Bootsnap is present, it precompiles the cache; if a
`Rakefile` exists, it runs `rake assets:precompile` when that task is available.
These environment variables are set for you automatically:
- `BUNDLE_PATH=/usr/local/bundle`
- `BUNDLE_WITHOUT=development`
- `RACK_ENV=production`
- `RAILS_ENV=production` (for Rails apps)
### Start command
Miren detects your web server. It must bind to `0.0.0.0` on `$PORT` — Miren injects
`PORT` and routes traffic to it.
| Framework | Detected entrypoint |
|-----------|---------------------|
| Rails | `bundle exec rails server -b 0.0.0.0 -p $PORT` |
| Puma (with config) | `bundle exec puma -C config/puma.rb` |
| Puma (no config) | `bundle exec puma -b tcp://0.0.0.0 -p $PORT` |
| Rack | `bundle exec rackup -p $PORT` |
Override with a `Procfile` to add workers or change the command:
```procfile
# Rails web server
web: bundle exec rails server -b 0.0.0.0 -p $PORT
# Sidekiq background worker
worker: bundle exec sidekiq
```
Use a single `web:` line. To run Puma with a config file instead of the Rails command:
```procfile
web: bundle exec puma -C config/puma.rb
```
See [Services](../services.md) for running Sidekiq or other workers alongside web.
## Environment variables
`miren init` stages the secrets a Rails app needs on first deploy: it **generates**
`SECRET_KEY_BASE` and **reads** `RAILS_MASTER_KEY` from `config/master.key` (or
`config/credentials/production.key`) if present, pre-setting both on the app. See
[What `miren init` Does for You](../app-configuration.md#what-miren-init-does-for-you).
Set anything else with `miren env set` — `-e` for plain values, `-s` for secrets
(masked in output and logs):
```miren
miren env set -s DATABASE_URL
miren env set -s RAILS_MASTER_KEY=@config/master.key
miren env set -s SECRET_KEY_BASE
```
`KEY=@file` reads the value from a file; `-s SECRET_KEY_BASE` (no value) prompts with
masked input. You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** `Gemfile` in the project
- **Default version:** Ruby 3.4 (override via `[build] version` in `.miren/app.toml`)
- **Install:** `bundle install` with `BUNDLE_WITHOUT=development`
- **Auto env:** `RACK_ENV=production`, `RAILS_ENV=production`, `BUNDLE_PATH`, `BUNDLE_WITHOUT`
- **Staged by `miren init`:** generates `SECRET_KEY_BASE`, reads `RAILS_MASTER_KEY` from `config/master.key`
- **Start command:** Rails/Puma/Rack auto-detected, bind `0.0.0.0:$PORT`; or set a `Procfile`
- **Env vars:** `miren env set -e/-s`, `KEY=@file` to read from disk, or `[[env]]` in `app.toml`
- **Dockerfile:** not needed; add `Dockerfile.miren` only for custom builds
## Next steps
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Services](../services.md) — web + Sidekiq workers
- [Deployment](../deployment.md) — how deploys build and activate
---
## Rust on Miren
Miren auto-detects Rust apps from `Cargo.toml`, builds them with Cargo, and ships a
single release binary at `/bin/app` on a minimal runtime image — no Dockerfile required.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Rust app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It confirms your binary, checks the server binds
`0.0.0.0:$PORT`, wires up environment variables, and deploys — using this page as its
reference.
:::
## Does this source build need a Dockerfile?
No. Miren detects Rust from `Cargo.toml` and runs `cargo build --release` for you. The
default is the **latest Rust 1.x**. Provide a `Dockerfile.miren` only for custom build
steps — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
## Set up the app
From your project root:
```miren
miren init
miren deploy
```
Preview what Miren detects — binary name, version, entrypoint — without building:
```miren
miren deploy --analyze
```
### Build process
Miren builds on the official Rust base image, runs `cargo build --release`, and copies
the resulting binary to `/bin/app`.
### Binary name
Miren reads `Cargo.toml` to find the binary, then copies it to `/bin/app`:
1. Uses the `[[bin]]` name if specified.
2. Otherwise uses the package name from `[package]`.
### Start command
Your server must bind to `0.0.0.0` on `$PORT` — Miren injects `PORT` and routes traffic
to it. Read it with `std::env::var("PORT")`. Here's a minimal [axum](https://docs.rs/axum)
server:
```rust
use axum::{routing::get, Router};
use std::env;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello from Rust on Miren!\n" }));
let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
```
The compiled binary lands at `/bin/app`, which is the default start command. Use a
`Procfile` only to pass flags or define extra processes:
```procfile
# Default — run the compiled binary
web: /bin/app
# Background worker
worker: /bin/app --mode worker
```
### Example Cargo.toml
```toml
[package]
name = "myapp"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked
in output and logs):
```miren
miren env set -e RUST_LOG=info
miren env set -s DATABASE_URL
miren env set -s API_TOKEN
```
`miren env set -s API_TOKEN` (no value) prompts with masked input. You can also declare
variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** `Cargo.toml` in the project
- **Default version:** latest Rust 1.x (override via `[build] version` in `.miren/app.toml`)
- **Build:** `cargo build --release`; binary copied to `/bin/app`
- **Binary name:** `[[bin]]` name if set, else the `[package]` name
- **Start command:** `web: /bin/app` (default); bind `0.0.0.0:$PORT` via `std::env::var("PORT")`
- **Env vars:** `miren env set -e KEY=VALUE`, `-s` for secrets, or `[[env]]` in `app.toml`
- **Dockerfile:** not needed; add `Dockerfile.miren` only for custom builds
## Next steps
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Services](../services.md) — web + workers
- [Deployment](../deployment.md) — how deploys build and activate
---
## Scala on Miren
Scala isn't auto-detected, so you deploy it with a `Dockerfile.miren` that packages a
runnable jar. This guide uses [scala-cli](https://scala-cli.virtuslab.org) with the
[Cask](https://com-lihaoyi.github.io/cask/) framework — the least-ceremony path. The
same jar-and-`java -jar` pattern works for sbt builds with http4s or Play.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Scala app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds the server to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Scala, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so read `PORT` and bind `0.0.0.0`. A Cask
app declares its `port` and `host`:
```scala
//> using dep com.lihaoyi::cask:0.9.4
object App extends cask.MainRoutes {
override def port = sys.env.getOrElse("PORT", "8080").toInt
override def host = "0.0.0.0"
@cask.get("/")
def hello() = "Hello from Scala on Miren!\n"
initialize()
}
```
The `//> using dep` line lets scala-cli resolve dependencies without a separate build
file.
## The Dockerfile
Create `Dockerfile.miren` in your project root. scala-cli packages an assembly jar,
which runs on a plain JRE:
```dockerfile
# ----- Build stage -----
FROM virtuslab/scala-cli:latest AS builder
WORKDIR /app
COPY . .
RUN scala-cli --power package app.scala -o app.jar --assembly
# ----- Runtime stage -----
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/app.jar /app/app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
```
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "scala-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `sys.env.get("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** `scala-cli --power package app.scala -o app.jar --assembly`; run on a JRE image
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `sys.env.getOrElse("PORT", "8080").toInt`; Cask `override def host = "0.0.0.0"`
- **Env vars:** `miren env set -e/-s`; read with `sys.env.get`
## Next steps
- [Java on Miren](./java.md) — the JVM sibling guide
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## Static sites & SPAs on Miren
Miren can export files from an app image into a dedicated artifact at deploy
time and serve them directly from HTTP ingress, without mounting the image or
running a sandbox. Set `static.dir` to the absolute directory containing the
built site. If you need SPA fallback or custom rewrites, run a web server such as
Caddy instead.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Vite app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It sets `static.dir`, adds a build or
`Dockerfile.miren` when needed, and deploys — using this page as its reference.
:::
## Does this site need a Dockerfile?
Not when the repository already contains the files to serve. Miren maps the
uploaded source tree to `/app`, so this serves the local `site/` directory
directly without building an image:
```toml
[static]
dir = "/app/site"
```
A generated site whose tool is not natively detected needs a `Dockerfile.miren`.
Miren builds it before exporting `static.dir` — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Serve files directly
For a plain static site, put the files in a directory such as `site/`:
```text
site/
├── index.html
├── styles.css
└── images/
```
Then point ingress at its path under `/app`:
```toml title=".miren/app.toml"
name = "static-site"
[static]
dir = "/app/site"
```
For a generated site, use a build stage and copy only its output into the final image:
```dockerfile title="Dockerfile.miren"
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM scratch
COPY --from=builder /app/dist /app/site
```
Ingress serves regular files and directory `index.html` files. Missing paths return
404. No service or sandbox is created for this configuration. If the app also declares
a `web` service, missing files fall through to that service instead.
## Use Caddy for SPA fallback
Caddy reads the injected `$PORT` and serves your build directory, falling back to
`index.html` so client-side routing works. Create a `Caddyfile`:
```caddyfile
:{$PORT:8080} {
root * /site
try_files {path} /index.html
file_server
}
```
`{$PORT:8080}` uses the `PORT` environment variable Miren injects, defaulting to 8080
for local runs.
## The Dockerfile
For a plain static site, copy your files and the Caddyfile into the Caddy image:
```dockerfile
FROM caddy:2-alpine
COPY site /site
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 8080
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
```
For a built SPA (Vite, Astro, Create React App), add a build stage and copy the output
into `/site`:
```dockerfile
# ----- Build stage -----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ----- Serve stage -----
FROM caddy:2-alpine
COPY --from=builder /app/dist /site
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 8080
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
```
Point the `COPY --from=builder` at your framework's output directory (`dist` for Vite,
`build` for Create React App, `dist` for Astro).
### .dockerignore
```text
.git
node_modules
dist
```
## Deploy
The Dockerfile's `CMD` starts the app, so you don't need a `Procfile` or service command.
The Caddy base image exposes several HTTP, HTTPS, and admin ports; select this app's HTTP
port explicitly.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "static-bench"
[services.web]
port = 8080
```
```miren
miren deploy
```
## Environment variables
Static assets are built at image-build time, so runtime environment variables don't
reach the browser. Bake build-time configuration in during `npm run build` (e.g. Vite's
`VITE_*` variables), or fetch runtime config from an API your SPA calls.
If you do need a value at build time, pass it as a Docker build arg and reference it in
your build step. See [App Configuration](../app-configuration.md) for how Miren handles
configuration.
## Agent quick reference
- **Detection:** `static.dir` is sufficient for source that is already static; generated sites need a detected stack or `Dockerfile.miren`
- **Direct serve:** set `static.dir` to export and serve built files without a sandbox
- **SPA fallback:** use `caddy:2-alpine` with a `Caddyfile` using `:{$PORT:8080}` and `try_files {path} /index.html`
- **SPA build:** add a `node:20-alpine` build stage, copy the output dir into `/site`
- **Startup:** direct serving needs no `CMD`, `Procfile`, or service command
- **Port:** Caddy binds `:{$PORT}` from the environment
- **Runtime env:** not visible to the browser; use build-time `VITE_*` vars or a runtime config API
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
- [JavaScript on Miren](./javascript.md) — if you also run a Node/Bun backend
---
## Swift on Miren
Swift isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles your
app and runs the binary. This guide uses [Vapor](https://vapor.codes), the main
server-side Swift framework.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Vapor app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, binds the server to
`0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Swift, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so set Vapor's server hostname to
`0.0.0.0` and the port from the environment:
```swift
var env = try Environment.detect()
let app = Application(env)
defer { app.shutdown() }
app.get { req in "Hello from Swift on Miren!\n" }
app.http.server.configuration.hostname = "0.0.0.0"
app.http.server.configuration.port = Int(Environment.get("PORT") ?? "8080") ?? 8080
try app.run()
```
A `Package.swift` declaring Vapor and an executable target:
```swift
// swift-tools-version:5.10
let package = Package(
name: "swift-bench",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.106.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [.product(name: "Vapor", package: "vapor")]
),
]
)
```
## The Dockerfile
Create `Dockerfile.miren` in your project root. `--static-swift-stdlib` links the Swift
runtime into the binary so the runtime image stays small:
```dockerfile
# ----- Build stage -----
FROM swift:5.10-jammy AS build
WORKDIR /app
COPY . .
RUN swift build -c release --static-swift-stdlib
# ----- Runtime stage -----
FROM ubuntu:jammy
RUN apt-get update -y && apt-get install -y ca-certificates libcurl4 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/.build/release/App /app/App
EXPOSE 8080
CMD ["/app/App"]
```
:::info[Builds are slow]
Vapor pulls in swift-crypto, which compiles BoringSSL from C/C++ — the first build takes
several minutes. Miren caches image layers, so rebuilds that don't change dependencies
are faster.
:::
### .dockerignore
```text
.git
.build
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "swift-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `Environment.get("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s DATABASE_URL
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren`
- **Build:** `swift build -c release --static-swift-stdlib` on `swift:5.10-jammy` (slow — BoringSSL)
- **Runtime:** `ubuntu:jammy` + `ca-certificates libcurl4`; binary at `.build/release/`
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** `Environment.get("PORT")`; set `app.http.server.configuration.hostname = "0.0.0.0"`
- **Env vars:** `miren env set -e/-s`; read with `Environment.get`
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## TruffleRuby on Miren
TruffleRuby is an alternative Ruby implementation built on GraalVM. Miren auto-detects
standard (MRI) Ruby from a `Gemfile` — see [Ruby on Miren](./ruby.md) — but to pin a
specific runtime like TruffleRuby you use a `Dockerfile.miren`. Your Ruby code and gems
run unchanged.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this app on TruffleRuby on Miren" after installing
the [Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, wires up Bundler
and the server, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes — to run on TruffleRuby specifically. (Miren's auto-detection would pick MRI Ruby.)
Add a `Dockerfile.miren` built on the GraalVM TruffleRuby image. See
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## The app
A normal Sinatra app with a `Gemfile` — nothing TruffleRuby-specific:
```ruby
# Gemfile
source 'https://rubygems.org'
gem 'sinatra'
gem 'puma'
gem 'rackup'
```
```ruby
# app.rb
require 'sinatra/base'
class App < Sinatra::Base
# Sinatra 4 rejects unknown Host headers; an empty list permits any host.
set :host_authorization, permitted_hosts: []
get '/' do
content_type 'text/plain'
"Hello from TruffleRuby on Miren!\n"
end
end
```
:::warning[Sinatra 4 blocks the deploy host]
Sinatra 4 enables `Rack::Protection::HostAuthorization`, which only allows requests whose
`Host` header is on an allowlist (localhost by default). Behind Miren's router your app is
reached at its route hostname, so without configuration it returns `Host not permitted`.
Set `host_authorization` to an empty `permitted_hosts` list (allow any), or list your
actual hostnames. This applies to any Sinatra 4 app, MRI included.
:::
```ruby
# config.ru
require './app'
run App
```
Miren injects `PORT` and routes to it; the Dockerfile's Puma `CMD` below binds
`0.0.0.0` on that port.
## The Dockerfile
Create `Dockerfile.miren` in your project root, built on the official GraalVM community
image, and install gems with Bundler:
```dockerfile
FROM ghcr.io/graalvm/truffleruby-community:latest
WORKDIR /app
COPY Gemfile ./
RUN bundle install
COPY . /app
EXPOSE 8080
CMD ["sh", "-c", "exec bundle exec puma -b tcp://0.0.0.0:$PORT"]
```
### .dockerignore
```text
.git
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
TruffleRuby warms up slowly (GraalVM startup plus gem loading), so raise `port_timeout`
past the 15-second default, and keep one instance always running instead of autoscaling
to zero. Set both in `.miren/app.toml`:
```toml
name = "truffleruby-bench"
[services.web]
# GraalVM Ruby boots slowly; give it more than the 15s default to bind.
port_timeout = "120s"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
```
```miren
miren deploy
```
:::warning[GraalVM boot vs. the port timeout]
By default Miren waits 15 seconds for a service to bind its port, then reports
`nothing is listening after the port timeout`. TruffleRuby's startup plus Bundler and
Puma routinely exceeds that. Raise `port_timeout` (e.g. `"120s"`) so the health check
waits long enough, and use fixed scaling so a warm instance stays up.
:::
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `ENV['KEY']`:
```miren
miren env set -e RACK_ENV=production
miren env set -s DATABASE_URL
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** MRI Ruby is auto-detected from a `Gemfile`; use `Dockerfile.miren` to pin TruffleRuby
- **Base image:** `ghcr.io/graalvm/truffleruby-community:latest`; `bundle install` at build time
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Slow boot:** raise `port_timeout` (e.g. `"120s"`) past the 15s default, and pin `num_instances = 1` to stay warm
- **Port:** Puma `-b tcp://0.0.0.0:$PORT`
- **Env vars:** `miren env set -e/-s`; read with `ENV['KEY']`
## Next steps
- [Ruby on Miren](./ruby.md) — auto-detected MRI Ruby
- [JRuby on Miren](./jruby.md) — Ruby on the JVM
- [Application Scaling](../scaling.md) — fixed vs. autoscaling
---
## Zig on Miren
Zig isn't auto-detected, so you deploy it with a `Dockerfile.miren` that compiles your
app to a single static binary and runs it on a minimal image.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Zig app on Miren" after installing the
[Miren agent skills](../agent-skills.md). It adds the `Dockerfile.miren`, confirms your
server binds `0.0.0.0:$PORT`, and deploys — using this page as its reference.
:::
## Does this source build need a Dockerfile?
Yes. Miren doesn't auto-detect Zig, so add a `Dockerfile.miren` to your project root.
Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::info[Zig's standard library moves fast]
The example below is validated against **Zig 0.14**. The `std.net` and `std.http` APIs
change between releases — if you pin a different Zig version, expect to adjust the socket
code.
:::
:::tip[Want native support?]
Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
## Bind to the injected port
Miren injects `PORT` and routes traffic to it, so your server must read `PORT` and
listen on `0.0.0.0`. A minimal listener using `std.net` (Zig 0.14):
```zig
// src/main.zig — build.zig points root_source_file here
const std = @import("std");
pub fn main() !void {
const port_str = std.posix.getenv("PORT") orelse "8080";
const port = try std.fmt.parseInt(u16, port_str, 10);
const addr = try std.net.Address.parseIp("0.0.0.0", port);
var server = try addr.listen(.{ .reuse_address = true });
defer server.deinit();
const body = "Hello from Zig on Miren!\n";
while (true) {
const conn = server.accept() catch continue;
defer conn.stream.close();
var buf: [1024]u8 = undefined;
_ = conn.stream.read(&buf) catch 0;
var out: [256]u8 = undefined;
const resp = std.fmt.bufPrint(&out, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n{s}", .{ body.len, body }) catch continue;
conn.stream.writeAll(resp) catch {};
}
}
```
A matching `build.zig`:
```zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "zigapp",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(exe);
}
```
## The Dockerfile
The official Zig release is a tarball, so the build stage downloads it and cross-compiles
a static musl binary:
```dockerfile
ARG ZIG_VERSION=0.14.0
# ----- Build stage -----
FROM alpine:3.20 AS builder
RUN apk add --no-cache curl xz
ARG ZIG_VERSION
RUN curl -sSL https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz \
| tar -xJ -C /opt \
&& ln -s /opt/zig-linux-x86_64-${ZIG_VERSION}/zig /usr/local/bin/zig
WORKDIR /app
COPY . .
RUN zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl
# ----- Runtime stage -----
FROM alpine:3.20
COPY --from=builder /app/zig-out/bin/zigapp /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
```
Miren's cluster builds on `x86_64`, so the download URL and `-Dtarget` use `x86_64`.
:::tip[Verify the tarball for production]
This pipes the release tarball straight into `tar` without checking it. For images you'll
run in production, verify the download against Zig's published SHA256 (listed alongside
each release at [ziglang.org/download](https://ziglang.org/download)) before extracting it.
:::
### .dockerignore
```text
.git
zig-out
.zig-cache
```
## Deploy
The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
so you don't need a `Procfile` or service command.
Create `.miren/app.toml` naming your app and deploy from your project root:
```toml
name = "zig-bench"
```
```miren
miren deploy
```
## Environment variables
Set variables with `miren env set` — `-e` for plain values, `-s` for secrets (masked in
output and logs). Read them with `std.posix.getenv("KEY")`:
```miren
miren env set -e LOG_LEVEL=info
miren env set -s API_TOKEN
```
You can also declare variables in `.miren/app.toml`:
```toml
[[env]]
key = "API_TOKEN"
value = ""
required = true
sensitive = true
```
See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
## Agent quick reference
- **Detection:** none — requires `Dockerfile.miren` (static binary)
- **Build:** download Zig tarball, `zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl`
- **Runtime:** copy `zig-out/bin/` to a minimal Alpine image
- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
- **Port:** read `std.posix.getenv("PORT")`; bind `0.0.0.0`
- **Std API churn:** pin a Zig version; `std.net`/`std.http` change between releases
## Next steps
- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
- [Deployment](../deployment.md) — how deploys build and activate
---
## In-Cluster API Access
Code running inside a Miren sandbox can call the cluster's own API — deploy, read logs, open a shell, inspect status — authenticating as *itself*, with no API key to store or rotate. It's the Miren equivalent of a Kubernetes pod using its service-account token to reach the Kubernetes API.
Every sandbox already carries a [workload identity token](./workload-identity.md). That page is about presenting the token *outward* (to AWS, GCP, your own APIs). This page is about pointing it *inward*: using it to talk to Miren, and choosing what a given app's workloads are allowed to do once they do.
:::info[Two directions, one token]
- **[Workload Identity](./workload-identity.md)** — present the token to *external* services to prove which workload you are.
- **In-Cluster API Access** (this page) — present the same token to the *Miren* API, scoped by a role.
:::
Typical uses: a CI job running inside a sandbox that redeploys its own app, a sidecar that tails its app's logs, an operator tool that reads status across the cluster, or an app that manages its own configuration.
## Minimum working example
From inside any sandbox with the `miren` binary in its image, the CLI authenticates automatically:
```bash
miren app status
miren logs
```
That works out of the box under the default `app-readonly` role. To let an app do more — redeploy itself, for example — raise its role in `.miren/app.toml`:
```toml
name = "my-app"
workload_role = "app-deployer"
```
## Connecting
From inside a sandbox, the `miren` CLI just works — it detects that it's running in-cluster and authenticates with the mounted token:
```bash
miren app status # your own app
miren logs # your own app's logs
```
No `miren login`, no config file. This assumes the `miren` binary is in your image — nothing mounts it into the sandbox, so a distroless or slim image needs the CLI installed first. If a config file *is* present (for example you ran `miren login` yourself), that wins — so you can still point the CLI at another cluster from inside a sandbox.
Miren wires this up through environment variables it injects into every sandbox:
| Environment variable | Value | Use |
| --- | --- | --- |
| `MIREN_IN_CLUSTER` | `1` | Marks the process as running inside a sandbox |
| `MIREN_API_ADDRESS` | e.g. `10.x.x.1:8443` | The cluster API address, reachable from the sandbox |
| `MIREN_CA_CERT_PATH` | `/var/run/miren/ca.crt` | The cluster CA, used to verify the API's certificate |
| `MIREN_IDENTITY_TOKEN_PATH` | `/var/run/miren/identity-token` | The token presented on each request |
The connection is verified against the cluster CA — it is *not* insecure. Because sandboxes share a network bridge, skipping verification would let a neighbor impersonate the API, so the CA is mounted and checked.
## Roles
A token authenticates as a **role**, and the role decides what it may call. Confinement is per app: an *app-scoped* role can only act on the app the sandbox belongs to, while a *cluster-scoped* role reaches across the whole cluster.
The default is **`app-readonly`** — a fresh app's workloads can read their own status and logs, and nothing else. You opt up from there.
| Role | Scope | What it grants |
| --- | --- | --- |
| `none` | — | Authenticates but authorizes nothing |
| `app-readonly` *(default)* | own app | Read own app status, logs, and deployment history |
| `app-deployer` | own app | `app-readonly` + build and (re)deploy its own app |
| `app-debugger` | own app | `app-readonly` + open a shell / run commands in its own sandboxes |
| `app-admin` | own app | `app-deployer` + edit config/env and exec — everything for its own app except deleting it |
| `cluster-readonly` | cluster | Read status, logs, and infrastructure state across all apps |
| `cluster-deployer` | cluster | `cluster-readonly` + build, deploy, and configure any app (but not create or destroy apps) |
| `cluster-debugger` | cluster | `cluster-readonly` + exec and inspect any app |
| `cluster-admin` | cluster | Broad control across the cluster (see the limits below) |
No role — not even `cluster-admin` — can mint identity tokens for other sandboxes, perform raw entity-store writes, or reconfigure the cluster's network fabric. Those stay on the internal operator (certificate) plane. A role is a bearer token mounted in a sandbox; these limits bound the blast radius if one leaks.
### Choosing a role
There are two ways to set an app's role, and they draw the line between what an app owner can do and what an operator can do.
**In `.miren/app.toml`** — self-service, for app owners:
```toml
name = "my-app"
workload_role = "app-deployer"
```
:::warning[app.toml can only set app-scoped roles]
An app confining a token to its own app isn't an escalation — you already control the app. Declaring a cluster-scoped role here fails the deploy, with a message pointing you at the operator command.
:::
**With the CLI** — the operator path:
```bash
miren app set-workload-role -a my-app app-admin
miren app set-workload-role -a tooling cluster-readonly
```
:::warning[Cluster-scoped roles are operator-only]
`set-workload-role` is not reachable by in-sandbox workloads or app-scoped deploy identities — only a cluster operator can call it. That's what stops an app owner from self-granting cluster-wide access.
:::
You can see an app's current role in `miren app status`.
## Sharp edges
:::warning[A role change takes effect on the next sandbox build]
The role is baked into a sandbox's token when the sandbox is created, and the background token refresh preserves it. Changing a role — via `app.toml` on a redeploy, or `set-workload-role` — applies to sandboxes built *after* the change. A redeploy that reuses an existing pool keeps the old role until those sandboxes are actually rebuilt (for example by a deploy that changes the app, or a restart that recreates instances).
:::
:::warning[Grant cluster roles sparingly]
A cluster-scoped role is a powerful credential sitting on disk inside a sandbox. Prefer the narrowest role that does the job — an `app-*` role for anything that only touches one app — and reserve cluster roles for genuine cross-app tooling. Tokens are short-lived (refreshed on a fixed loop), which bounds exposure, but that is not a substitute for least privilege.
:::
:::warning[Removing `workload_role` from app.toml does not revoke it]
Deleting the line leaves the last value in place — a deploy with no `workload_role` doesn't clear it. To drop back to the minimum, set `workload_role = "none"` explicitly (or `app-readonly`), which revokes on the next build.
:::
:::note[The API is only reachable from inside the cluster]
`MIREN_API_ADDRESS` is the API as seen from the sandbox network. It is not a public endpoint and not a stable value — always read it from the environment rather than hardcoding it.
:::
## See also
- [Workload Identity](./workload-identity.md) — the token itself, and presenting it to external services
- [app.toml Reference](./app-toml.md) — the `workload_role` field
---
## Miren
:::tip[Working with an AI coding agent?]
Point it at [llms.txt](https://miren.md/llms.txt) or [llms-full.txt](https://miren.md/llms-full.txt) for LLM-friendly docs, or install [Miren agent skills](https://github.com/mirendev/miren-skills) so it can deploy and manage your apps directly.
:::
# Miren
Miren is a container platform for small teams. You install it on a Linux server, point your CLI at it, and deploy with `miren deploy`. It handles builds, scaling, routing, TLS, and backups so you don't have to stitch together a platform from parts.
## How it works
Miren has two sides. The **server** runs on your Linux machine and manages containers, networking, and storage. The **client** is the `miren` CLI on your laptop (or CI runner), which talks to the server over a secure connection.
You deploy apps by running `miren deploy` from your project directory. Miren can run a configured container image directly, or detect your language (Python, Node, Bun, Go, Ruby, Rust, or a Dockerfile) and build one from your source. Your first app gets a route automatically. Scaling is automatic by default, adjusting instance counts based on traffic.
If your server is on a home network or behind a firewall, [Miren Anywhere](./miren-cloud/miren-anywhere.md) still puts your apps on the internet, routing their traffic through Miren Cloud so you don't need port forwarding or a public IP of your own.
Configuration lives in `.miren/app.toml` in your project. Environment variables, secrets, services, scaling behavior, and persistent disks are all managed through the CLI and this config file.
## Get started
**[Getting Started](./getting-started.md)** walks you through installation, server setup, and deploying your first app in a few minutes.
## Learn more
- [App Configuration](./app-configuration.md) - `.miren/app.toml` and how to configure your app
- [Deployment](./deployment.md) - Deploy workflows, rollbacks, and CI integration
- [Pull Request Environments](./pr-environments.md) - Time-boxed preview deploys, one per PR
- [Services](./services.md) - Run multiple processes (web, workers, databases) in one app
- [Scaling](./scaling.md) - Autoscaling and fixed instance modes
- [Traffic Routing](./traffic-routing.md) - Custom domains, TLS, and path-based routing
- [Disks](./disks.md) - Persistent storage with backup and restore
- [Logs](./logs.md) - Application, build, and system logs
- [CLI Reference](./commands.md) - Every command and flag
---
## Miren Labs
Miren Labs is where we ship experimental features that aren't quite ready for prime time. These are capabilities we're actively developing and want to get into your hands early for feedback.
## What to Expect
Labs features are:
- **Experimental** — APIs and behavior may change based on feedback
- **Opt-in** — Off by default, you choose when to try them
- **Reversible** — Anything you turn on you can turn back off
- **Supported** — We want to hear about bugs and rough edges
- **On a path** — Most labs features are headed toward stable release
When a feature graduates to stable it flips on by default and stops being opt-in, but we leave its flag in place for a release so you can turn it back off if the new behavior causes trouble. After that release the flag and the old behavior both go away.
## Enabling Labs Features
Labs features are controlled via the `--labs` flag or `MIREN_LABS` environment variable when starting the Miren server.
```miren
# Enable a labs feature
miren server --labs
# Via environment variable
MIREN_LABS= miren server
```
To name more than one feature, repeat `--labs` or comma-separate the
environment variable.
## Turning a Feature Off
Prefix a feature name with `-` to disable it, using the same flag and
environment variable as enabling. This is mainly how you back out of a feature
that has graduated to on by default, for the one release its flag sticks around
as an escape hatch.
```miren
# Turn a feature off
miren server --labs -
# Via environment variable
MIREN_LABS=- miren server
```
Check the server log to confirm the setting landed. Every boot logs a `labs features` line listing where each feature ended up, whether or not you named it.
## Giving Feedback
We'd love to hear how labs features work for you:
- **What's working well** — Helps us know we're on the right track
- **What's confusing** — Documentation gaps, unclear behavior
- **What's broken** — Bugs, crashes, unexpected behavior
- **What's missing** — Features that would make it useful for your use case
Reach out via [Discord](https://miren.dev/discord) or open an issue on [GitHub](https://github.com/mirendev/runtime/issues).
## Current Labs Features
Individual labs features are documented alongside their related functionality. Look for the "Labs Feature" callout in the docs.
---
## Logs
Miren captures logs from your applications, sandboxes, builds, and the system itself. Logs are stored in [VictoriaLogs](https://docs.victoriametrics.com/victorialogs/) and queryable through the `miren logs` command and its subcommands.
## Minimum working example
```miren
# Last 100 lines for the current app
miren logs
# Follow in real time, filtered to errors
miren logs -f -g error
```
Add `-a myapp` to target a specific app. Build, sandbox, and system logs each have their own subcommand, covered below.
## Subcommands
| Subcommand | Description |
|------------|-------------|
| `miren logs app` | Application logs (default when no subcommand is given) |
| `miren logs sandbox` | Logs from a specific sandbox instance |
| `miren logs build` | Build output for a specific app version |
| `miren logs system` | Miren server internal logs |
Running `miren logs` without a subcommand shows app logs (backward compatible).
## Viewing app logs
App logs are the most common use case. By default, `miren logs` shows the last 100 lines from your current app:
```miren
# Show recent logs for the current app
miren logs
# Show logs for a specific app
miren logs app -a myapp
# Filter by service
miren logs app -a myapp --service web
# Show logs from the last 30 minutes
miren logs app --last 30m
# Follow logs in real-time
miren logs app -a myapp -f
```
## Build logs
View the output from a specific build to debug deployment failures:
```miren
# Show build logs for the latest version
miren logs build -a myapp
# Show build logs for a specific version
miren logs build -a myapp VERSION
```
Replace `VERSION` with the version from `miren app history`.
## Sandbox logs
View logs from a specific sandbox instance, useful for debugging issues in individual containers:
```miren
# Show logs for a specific sandbox
miren logs sandbox -s SANDBOX_ID
```
Use `miren sandbox list` to find sandbox IDs.
## System logs
View Miren server internal logs for debugging server-level behavior:
```miren
miren logs system
```
System logs contain output from the Miren server process itself — controller reconciliation, RPC calls, sandbox lifecycle events, and other internal operations. Use these when debugging server behavior rather than application issues.
## Filtering with grep
Use `--grep` (or `-g`) to filter log output. The filter supports multiple syntax options:
| Syntax | Description | Example |
|--------|-------------|---------|
| `word` | Match logs containing "word" (case-insensitive) | `error` |
| `"phrase"` | Match logs containing exact phrase | `"connection failed"` |
| `'phrase'` | Match logs containing exact phrase (alternate) | `'connection failed'` |
| `/regex/` | Match logs matching regex pattern | `/err(or)?/` |
| `-term` | Exclude logs matching term | `-debug` |
| `term1 term2` | Match logs containing ALL terms (AND) | `error timeout` |
- **Case-insensitive**: All word and phrase matches are case-insensitive
- **AND logic**: Multiple terms must all match for a log line to be included
- **Negation**: Prefix any term with `-` to exclude matching lines
- **Quotes**: Use double (`"`) or single (`'`) quotes for phrases with spaces
- **Regex**: Enclose patterns in forward slashes (`/pattern/`) for regex matching
```miren
# Filter for errors
miren logs -g error
# Exclude debug lines
miren logs -g "-debug"
# Match a phrase
miren logs -g '"connection refused"'
# Combine filters (must match both)
miren logs -g "error timeout"
```
## Time ranges
By default, logs show the last 100 lines. Use `--last` to specify a time range:
```miren
# Last 5 minutes
miren logs --last 5m
# Last hour
miren logs --last 1h
# Last 24 hours
miren logs --last 24h
```
## Following logs
Use `--follow` (or `-f`) to stream logs in real-time:
```miren
# Follow all logs
miren logs -f
# Follow logs for a specific app
miren logs app -a myapp -f
```
## Output format
Log entries are displayed with the following format:
```
S 2024-01-15 10:30:45: [source] Log message here
```
| Prefix | Meaning |
|--------|---------|
| `S` | stdout |
| `E` | stderr |
| `ERR` | error |
| `U` | user out-of-band |
Each entry includes a timestamp and an optional source identifier (such as a truncated sandbox ID) to help you trace which instance produced the log line.
---
## Maintenance Mode
Maintenance mode takes a hostname out of service and shows visitors a holding page instead. It's for the moments you plan for: a database migration that can't run under live traffic, a dependency cutover, an incident where you want the site quiet while you look at it.
It's one command in, and one command out.
## Minimum working example
```miren
miren route down app.example.com --reason "Upgrading the database"
miren route up app.example.com
```
Between those two commands, anyone visiting `app.example.com` gets a holding page that says the app's name and your reason. Your app keeps running the whole time.
## What it does and doesn't touch
Maintenance is a routing decision and only a routing decision. That's what makes it useful:
- **Your app keeps running.** Sandboxes aren't stopped or scaled down, so `miren app run` and one-shot migrations work normally during the window. Taking traffic off the route first is the entire point.
- **Other hostnames keep serving.** If `app.example.com` and `admin.example.com` both point at the same app, taking one down leaves the other alone.
- **Internal calls keep working.** Service-to-service requests inside the cluster resolve by app, not by hostname, so they're unaffected.
- **Preview URLs are covered.** Ephemeral preview subdomains like `feat-x.app.example.com` resolve through their base route, so they go down with it. Previews share the app's database, so a migration window that left them serving wouldn't be much of a window.
## Telling visitors what's happening
`--reason` is shown on the holding page, so write it for your visitors rather than for your team.
`--back-at` sets an expected return time. It takes a clock time, a duration, or a full timestamp:
```miren
miren route down app.example.com --reason "DB migration" --back-at 15:00
miren route down app.example.com --reason "DB migration" --back-at 30m
miren route down app.example.com --reason "DB migration" --back-at 2027-03-04T15:00:00Z
```
A bare clock time is read in your own timezone, and one that has already passed resolves to tomorrow, so `--back-at 09:00` at 3pm means 9am the next morning. An absolute timestamp has to be in the future; the server refuses one that has already passed rather than putting a stale estimate on the holding page.
:::warning[`--back-at` is a promise to visitors, not a timer]
It tells people when you expect to be back. It does not bring the route back. The route stays in maintenance until you run `miren route up`, however long that takes.
If the window runs past your estimate, the `Retry-After` header stops being sent, but the page keeps showing the time you gave. That is deliberate: a page still naming a time that has passed reads as overdue, which is the truth, where a page quietly dropping the estimate would read as if nothing were wrong.
:::
## What clients see
Visitors get **HTTP 503 Service Unavailable**. This is the right status for deliberate, temporary downtime, and it's the one search crawlers and uptime monitors already know how to read — a maintenance window served as a 500, or as a 200 with an apology, is how sites lose ranking and how on-call gets paged for a planned event.
Alongside it:
- `Retry-After`, when you gave a `--back-at` that is still in the future. Monitors and crawlers use it to decide when to come back.
- `Cache-Control: no-store`, so no browser or CDN holds the holding page past the end of the window.
Clients that ask for JSON get JSON rather than a web page, which keeps API consumers from having to parse HTML to find out what happened:
```json
{
"error": "maintenance",
"reason": "Upgrading the database",
"back_at": "2027-03-04T15:00:00Z"
}
```
## The default route
The default route catches every hostname that doesn't match a route of its own, so taking it down is the widest single command available. Because of that it asks for confirmation:
```miren
miren route down --default --reason "Cluster upgrade"
miren route up --default
```
Pass `--yes` to skip the prompt in a script. JSON output can't carry a prompt, so `--json` on the default route requires `--yes` too, and refuses without it rather than quietly becoming the way around the confirmation.
## Seeing what's down
`miren route list` has a SERVING column, and `miren route show` gives the full record — the reason, the expected return, who set it, and when:
```miren
miren route show app.example.com
```
`miren app status` also names any of the app's routes that are currently serving a holding page, so you don't have to already suspect maintenance to find it.
## A migration, start to finish
```miren
miren route down app.example.com --reason "Upgrading the database" --back-at 15:00
miren app run -a web -- ./bin/migrate
miren route up app.example.com
```
The `miren app run` in the middle works because maintenance never touched the app's sandboxes — it only changed what the router does with inbound requests.
:::note[Verifying before you reopen]
There's no way to browse the real app from behind the holding page yet. Bring the route up and watch, or check the app through `miren app run` and `miren logs` while the window is still open.
:::
## What's not covered
:::warning[Background work keeps running]
Maintenance mode only affects HTTP traffic. Background workers and scheduled jobs carry on, so a window opened for a migration does not stop them writing to the database you are migrating. Stop them the way you normally would.
:::
Custom holding-page HTML and scheduled windows (announce a future window, enter and leave automatically) aren't supported yet.
---
## Managing Disk Space
Every deploy leaves something behind on the node: a container image, a snapshot, some registry blobs, build cache, logs. On a cluster that deploys often, all of that accumulates, and a node with a finite disk will eventually fill up. Miren reclaims this space automatically and continuously, so under normal use you never have to think about it. This page is the map for when you do — what accumulates, how each piece gets cleaned up, what you can tune, and what changes when the disk gets tight.
## Minimum working example
Cleanup is automatic and needs no configuration. The knob you're most likely to want — how much deploy history each app keeps — lives in the server config:
```toml title="/etc/miren/server.toml"
[app_version]
retention_count = 10 # always keep this many recent versions per app
retention_period = "14d" # keep anything younger than this when there's room
```
Aging versions out sooner lets the image cleanup cascade reclaim their disk over the next few sweeps. The rest of this page explains what accumulates and how each piece is reclaimed.
## What uses disk
A handful of things grow over time:
- **App versions and their images.** Every deploy mints a new version, and each version pins a container image, its overlayfs snapshot, and the registry blobs behind it. On a frequently-deployed cluster this is usually the largest and fastest-growing consumer.
- **Build cache.** BuildKit keeps a layer cache to make rebuilds fast.
- **Logs and metrics.** Application logs (VictoriaLogs) and metrics (VictoriaMetrics) are stored on the node with their own retention windows.
- **Persistent disks.** Your app's own data on local storage or Miren Disks. This is yours to size; Miren never reclaims it out from under you.
## How Miren reclaims space automatically
Each consumer has its own janitor running on its own schedule. Nothing here needs a cron job or a manual sweep — it all runs in the background.
| What accumulates | Reclaimed by | Default policy | Tune with |
|------------------|-------------|----------------|-----------|
| App versions | Version retention GC (hourly) | Keep the active version, the most-recent `retention_count` (10), and anything younger than `retention_period` (30d) | [`[app_version]`](./server-config.md#app-version) |
| Images, snapshots, registry blobs | Image + blob GC (weekly, plus hourly under pressure) | Reclaim images whose version has been pruned | follows version retention |
| Build cache | BuildKit | Cap at `gc_keep_storage` (10GB), evict entries older than `gc_keep_duration` (7d) | [`[buildkit]`](./server-config.md#buildkit) |
| Logs | VictoriaLogs | Keep `retention_period` (30d) | [`[victorialogs]`](./server-config.md#victorialogs) |
| Metrics | VictoriaMetrics | Keep `retention_period` (1 month) | [`[victoriametrics]`](./server-config.md#victoriametrics) |
| Deployment history | Deployment retention GC (hourly) | Keep the active and in-progress deployments, the most-recent `retention_count` (25), and anything younger than `retention_period` (30d); on a cloud-registered cluster, prune only what cloud has already stored | [`[deployment]`](./server-config.md#deployment) |
| Preview versions | Ephemeral GC (every 5 min) | Delete past their TTL (default 24h), cap 10 per app | `--ttl` at deploy |
| Deleted persistent disks | Deleted-volume GC (hourly) | Purge 7 days after deletion | see [Persistent Storage](./disks.md) |
The rest of this section walks through the two that matter most on a busy cluster; the others are covered by their config reference.
### App versions and their images
Version retention runs hourly and keeps, per app, three things: the active version, the most-recent `retention_count` versions, and any version younger than `retention_period`. Anything else is a candidate for deletion — except a version a running or pending sandbox still references, which is always kept until that sandbox is gone.
The part worth understanding is that deleting a version does *not* immediately free its image. Reclamation is a chain, and each link is a separate background controller running on its own schedule:
1. **Version GC deletes the version.** That removes the only thing linking the version to its underlying image artifact.
2. **Artifact GC notices the orphan.** On its next sweep it sees an image artifact that no surviving version references, and marks it archived. Because images are deduplicated by content, this happens only after the *last* version using that image is gone.
3. **Image GC deletes the archived image**, releasing its overlayfs snapshot so containerd can free the layers.
4. **Blob GC removes the registry blobs** that backed it.
So reclaiming an image is eventually-consistent: after a version ages out, its disk comes back over the next few cleanup cycles (the image and blob step runs weekly unless disk pressure pulls it forward), not the instant it's pruned. This is by design — every hop re-checks that nothing has started using the version or image in the meantime, so nothing in use is ever pulled out from under a running app.
### Preview versions
Preview (ephemeral) deploys are handled separately. Each gets a TTL at deploy time (default 24h, set with `--ttl`), and a sweep every five minutes deletes the ones that have expired, tearing down their sandbox pools and letting their images flow through the same cascade above. Miren also caps previews at 10 per app, retiring the oldest as new ones arrive. See [Pull Request Environments](./pr-environments.md) for the full preview workflow.
## Under disk pressure
Here's the catch that the automatic policy alone doesn't cover. Under normal operation, `retention_period` keeps roughly `deploys-per-day × 30 days` of versions around, and every one of them pins an image. Those versions haven't aged out, so the cascade above never starts for them, and the image GC has nothing to reclaim even as the disk fills. The age window is doing its job — it's just that on a high-deploy cluster the job is keeping *too much*.
So both the image GC and the version GC watch disk usage, and at **80%** they escalate:
- The **image GC**, which otherwise runs weekly, checks usage hourly and runs a full sweep the moment it crosses the threshold.
- The **version GC** drops its `retention_period` floor for that sweep. Retention collapses to the active version plus `retention_count` (and anything a live sandbox still pins), the surplus recent versions become deletable, and the cascade finally reclaims their images.
This is where the two retention knobs stop being equivalent. `retention_count` (plus the active version) is a **hard floor**, honored on every sweep regardless of pressure. `retention_period` is **best-effort**: it keeps younger versions around when there's room, but a disk-pressure sweep sheds it. Size `retention_count` for the history you genuinely can't lose, and treat `retention_period` as a convenience that yields when the disk is tight.
The 80% threshold is the same for both collectors, kept in sync so version pruning and image reclamation escalate together rather than one starving while the other waits.
## When your disk is filling up
The automatic policy handles the steady state, but if a node is running hot, here's where to look and what to reach for:
- **Images from frequent deploys** are the usual culprit. You don't have to wait for the 80% trigger — lower `retention_period` (or lean on `retention_count`) in [`[app_version]`](./server-config.md#app-version) to age versions out sooner, and the cascade reclaims their images on the next few sweeps.
- **Build cache** growing past what you expect: tighten `gc_keep_storage` in [`[buildkit]`](./server-config.md#buildkit).
- **Logs or metrics** eating space: shorten the `retention_period` for [`[victorialogs]`](./server-config.md#victorialogs) or [`[victoriametrics]`](./server-config.md#victoriametrics).
- **Persistent disk data** is your application's own, and Miren won't reclaim it. If a deleted disk is still holding space, remember there's a 7-day undelete window before the deleted-volume GC purges it for good — see [Persistent Storage](./disks.md).
---
## Cloud-Routed Clusters
Normally the `miren` CLI dials your cluster directly. That needs a route to it: a
public address, a VPN, or a tunnel. Plenty of clusters have none. A cluster
behind office NAT, on a home network, or inside a private subnet is perfectly
healthy and still unreachable from wherever you happen to be sitting.
Those clusters already hold an outbound connection to Miren Cloud, which is how
they report status and receive work. A cloud-routed cluster reuses that
connection in the other direction: the CLI connects to cloud, cloud passes the
traffic down the link the cluster already opened, and the cluster answers.
```
miren → Miren Cloud → the cluster's existing link → your cluster
```
Nothing new is opened on the cluster side, so there is no port to forward and no
firewall rule to add.
## Setting one up
Usually you do not have to do anything. `miren cluster add` picks this route on
its own when it is the one that works:
```bash
miren cluster add
```
If the cluster you pick advertises no address your machine can dial, or
advertises addresses that do not answer, the command tries the cloud route
itself: it opens a session through cloud and makes a call. If the cluster
answers, the entry is written to route through cloud and it tells you so. A
cluster that neither you nor cloud can reach still fails, because that is a real
problem rather than a routing choice.
It tries the route rather than asking whether one exists, because those are
different questions. Cloud can hold a link to a cluster whose runtime is older
than this feature, and that cluster will sit there without answering. Making the
call is what tells the two apart.
To skip the direct attempt entirely, ask for it:
```bash
miren cluster add --via-cloud
```
Either way you need to be logged in first (`miren login`), and the cluster needs
to be registered with cloud and online.
After that, use it like any other cluster:
```bash
miren cluster use my-cluster
miren app list
miren deploy
```
Deploys work over this route, including the build-context upload.
## What it does not change
**Your permissions are unchanged.** Cloud decides whether it will carry your
traffic at all, based on your membership of the organization that owns the
cluster. What you may actually *do* is decided by the cluster itself, from your
credential, against its own RBAC policy — exactly as when you connect directly.
Being able to reach a cluster this way does not grant you anything on it.
**Cloud does not read your traffic.** The frames it relays are the Miren RPC
protocol, which cloud passes along without interpreting. Your credential travels
inside them and is checked by the cluster.
**Audit still names you.** Calls arriving this way are attributed to you, not to
cloud.
## Limits worth knowing
**A dropped link ends in-flight commands.** Sessions live on the cluster's
connection to cloud. If that connection drops, anything in flight fails and the
cluster reconnects on its own schedule, which can take up to a minute. A long
deploy that spans an outage will fail and need re-running. When this happens the
error says so — if you see `the cluster's link to the cloud dropped`, retry
rather than going looking for a fault.
**It is slower than a direct connection.** Every frame takes an extra hop, and
the relay is not the path to choose when you have a direct one available.
**One cloud, one cluster.** The route is per-cluster. Clusters you can reach
directly should stay that way.
## Configuration
`miren cluster add --via-cloud` writes this for you; the fields are documented
here because a hand-written config is sometimes easier to reason about.
```yaml
clusters:
my-cluster:
via_cloud: true
xid: cluster-abc123 # the cluster's ID in cloud
identity: cloud # which login to authenticate with
```
The cloud used is the one your identity logged into. `cloud_url` overrides that,
which is what makes a cluster registered with one cloud reachable through
another:
```yaml
cloud_url: https://api.miren.cloud
```
A cloud-routed cluster needs no `address` and no `ca_cert`: it is never dialed,
and the certificate on the wire belongs to cloud.
### Development clouds
A cloud reached over plain `http://` is refused unless it is on this machine,
because everything about the connection is your credential and an unencrypted
hop puts all of it on the wire. To reach a development cloud by hostname, say so
explicitly:
```yaml
cloud_url: http://miren.host:3001
insecure: true
```
## Troubleshooting
**`cluster not connected`** — cloud has no live link to the cluster. Check the
[Connectivity](./connectivity.md) panel; the cluster is offline or has lost its
uplink.
**`access denied by RBAC policy`** — you reached the cluster and it refused the
command. That is the cluster's own policy, not the relay. A permission granted a
moment ago can take a short while to take effect.
**`the cluster's link to the cloud dropped`** — the cluster disconnected while
your command was running. Retry it.
---
## Cluster Connectivity
The **Connectivity** panel on a cluster's page answers three separate questions. They're easy to lump together, but they fail independently and they have different fixes, so Miren keeps them apart:
1. [Is the cluster online?](#is-the-cluster-online) — is it alive and checking in with Miren Cloud at all?
2. [Can Miren deploy to this cluster?](#can-miren-deploy-to-this-cluster) — can the CLI and the deploy path reach it over the internet?
3. [Can users reach your apps?](#can-users-reach-your-apps) — can visitors load the applications running on it?
A cluster can be online but not deployable, or undeployable over the internet yet still serving apps through the Miren POP network. Reading the three checks separately tells you exactly which layer is having trouble.
:::info[What this page covers]
This page explains what each check means and how to fix a "not reachable" verdict. For the exact ports and provider-specific firewall setup, see [Firewall Configuration](../firewall.md).
:::
## Is the cluster online?
This check is about liveness, not ports. Your Miren server checks in with Miren Cloud on a short interval; if Cloud has heard from it recently, the cluster reads as **Online**, and if there's also a live control link it reads as **Connected**. If check-ins stop, it goes **Offline**.
A cluster being online only means it's running and can talk *out* to Miren Cloud. It says nothing about whether anything can reach it from the outside — that's the next two checks.
:::note[Online but nothing else works?]
Outbound connectivity (the cluster reaching Cloud) and inbound connectivity (clients reaching the cluster) are different paths. A firewall commonly allows the first while blocking the second, which is exactly the case the next check catches.
:::
## Can Miren deploy to this cluster?
This is the **control plane**: the Miren API that the CLI and the deploy path use, served over QUIC on UDP port 8443. For `miren deploy`, `miren logs`, and everything else the CLI does to reach your cluster over the internet, this port has to be reachable.
When Miren can't reach it, the panel doesn't just say "Not reachable" — it names the culprit:
```text
Miren Cloud found this cluster at 203.0.113.10, but couldn't open a connection to it on UDP 8443 (QUIC).
```
That verdict comes from a reachability check Miren Cloud runs against the address your cluster reports (see [How Miren checks reachability](#how-miren-checks-reachability)). Cloud can see your public address but can't open a connection back to it on the port the control plane needs. Something in the inbound path is dropping UDP 8443 — most often a host firewall, a cloud-provider security group, or a NAT with no port forward for it.
To fix it, make sure inbound **UDP 8443** reaches your server end to end — through the host firewall, any cloud security group, and any NAT in front of it. The cluster becomes reachable the moment the port opens; the server's QUIC listener is already running, so nothing needs restarting. The dashboard catches up on its next reachability check (see the note below), or right away if you restart to force one:
```bash
sudo miren server restart
```
If your cluster's public address isn't the one Miren discovered — for example it sits behind a load balancer or a static NAT — set the reachable address explicitly with `additional_ips` in your [server configuration](../server-config.md) instead of relying on discovery.
:::tip[Reachable, but no public address]
A cluster on a private network (home lab, VPC with no public IP, or a host that only answers on a tailnet) will read "Not reachable" here and that's expected — you deploy to it from the same LAN or overlay. Miren Anywhere carries app traffic, not the control plane, and so **won't** change this check. For the tailnet case end to end, see [Running Miren on a Tailnet](../tailscale.md).
:::
## Can users reach your apps?
This check is about **application traffic** — the HTTP/HTTPS your visitors load, served on ports 80 and 443, entirely separate from the control-plane port above.
There are two ways users can reach your apps:
- **Directly**, when the cluster has a public address. The apps are served straight from your server.
- **Via Miren Anywhere**, when it doesn't. Miren routes app traffic through the Miren POP network, so your apps stay reachable even from behind NAT with no public address of their own.
This is why a cluster can show "Not reachable" for the control plane and still show apps as available: Miren Anywhere solves the app-traffic problem without solving the deploy problem. See [Custom subdomains](./subdomains.md) for how app hostnames are provisioned.
## How Miren checks reachability
Your Miren server reports the addresses it believes it's reachable at, and Miren Cloud runs a **netcheck**: from the outside, it looks at the public address the connection actually came from and probes the ports your server advertised. The result is what the Control plane check reports.
Three outcomes are possible:
- **Reachable** — Cloud connected to at least one advertised port. The address is confirmed and handed to clients.
- **Not reachable, address known** — Cloud saw a public address but couldn't connect to any port. Something in the inbound path (a firewall, a cloud security group, or a NAT) is dropping the traffic, and this is the verdict that names the specific port.
- **No public address** — Cloud only ever saw a private or carrier-NAT source, so there's nothing to hand out. Common for home labs, locked-down VPCs, and tailnet-only hosts; expected, not an error.
To see which addresses your cluster settled on and why each one was kept or dropped, run `miren debug advertise` on the host. It reproduces the server's own logic and prints a reason per candidate, which is usually faster than inferring the answer from the dashboard.
:::note[The panel reflects the last check]
Reachability is re-checked periodically (roughly hourly) and at startup, not on every status report — so after you open a port the panel can lag even though the cluster is already reachable. Restart the server to force a fresh check, or wait for the next one.
:::
---
## Miren Anywhere
Say your cluster runs on a home network, a laptop, or a locked-down VPC. It has no public IP, and you'd rather not punch holes in your router, pay for a static address, or wire up dynamic DNS just to put an app online. So how does the internet reach the apps you deploy?
That's what Miren Anywhere does. Your apps go public, Miren Cloud takes the inbound traffic at the edge, and your cluster never has to accept a single inbound connection. It reaches out to Miren Cloud, and traffic flows back down that path.
## How it reaches your apps
Exposing a private server the usual way means changing your network, and Miren Anywhere skips all of it. The only thing your cluster needs is the ability to make outbound connections, which almost every network already allows.
It works through a **POP** (Point of Presence): a Miren Cloud edge server that lives on the public internet, terminates TLS for your hostnames, and forwards requests to your cluster. Your cluster opens the connection *out* to the POP, so from your firewall's point of view nothing is listening and nothing is dialing in.
```mermaid
flowchart LR
visitor([Visitor on the internet])
pop["Miren POPedge server, holds your TLS cert"]
cluster["Your clusterno public IP, nothing dials in"]
visitor -->|HTTPS| pop
pop <-->|"QUIC tunnel(your cluster dialed out)"| cluster
```
:::info[Is my traffic encrypted?]
Yes, on every hop across the internet. Your visitors reach the POP over HTTPS, and the POP forwards their requests to your cluster over an encrypted QUIC (HTTP/3) connection, so nothing travels the public internet in the clear.
It isn't end-to-end, though. Like a CDN, the POP terminates TLS at its edge to route your traffic, so Miren Cloud decrypts each request in memory before passing it down the tunnel to you.
:::
## When Anywhere carries your traffic
For setups that clearly need it, Miren turns Anywhere on for you. A containerized install (Docker or Podman) usually can't accept inbound traffic from the internet, so Miren routes its apps through the POP network automatically when the cluster has no reachable public address.
Other installs start with Anywhere off. That's a deliberately cautious default: a cluster that's already reachable is working fine, and Miren would rather leave it alone than reroute it through the POP by mistake. When you want Anywhere on one of these clusters, you turn it on yourself.
You control this per cluster from the **Miren Anywhere** setting on the cluster's page in [Miren Cloud](./overview.md):
- **Default** — what a cluster starts on: Anywhere is on for containerized installs (Docker or Podman), which effectively can't accept inbound traffic, and off everywhere else.
- **Auto** — route through the POP network whenever the cluster has no reachable public address, whether or not it's containerized. A cluster that can be reached directly keeps serving its apps itself.
- **Always** — route through the POP network no matter what, even if the cluster has a public IP.
- **Never** — leave Anywhere off. Apps are reachable only where the cluster's own network reaches.
When a cluster routes via POP, Miren Cloud points its hostnames at the POP fleet; when it routes direct, those names point at the cluster's own address.
:::warning[Still evolving]
Miren Anywhere is under active development, and the automatic default is intentionally conservative while we harden the checks that decide when a cluster needs the POP network. Expect the defaults to open up, and the settings to change, as it matures.
:::
## Under the hood
You don't need this section to use Miren Anywhere. It's here for the curious.
When your cluster is registered with Miren Cloud, the miren server holds a long-lived WebSocket open to `miren.cloud`. That connection is how Cloud reaches back to a cluster that can't be dialed directly.
When a request for one of your hostnames arrives at a POP and the cluster isn't connected to that POP yet, the POP holds the request open while it brings the cluster online. It asks Cloud to wake the cluster, and Cloud relays that down the cluster's connection along with a one-time token. The cluster then dials the POP directly over QUIC (HTTP/3), presents the token, and the POP registers the link. The held request is forwarded down the new tunnel and the visitor gets their response a moment later, with no retry. From then on, requests for your hostnames terminate at the POP and get forwarded to your cluster over the encrypted tunnel.
The token handshake is what makes this safe behind carrier-grade NAT, where many clusters can share one public IP: the POP matches the connection to the right cluster by its token rather than its address.
## Limitations for now
Miren Anywhere is young, and there are edges it doesn't cover yet. Here's what to expect today.
**It covers your cluster's Miren-managed hostnames.** Traffic routes through the POP network for your cluster's `cluster-xyz.miren.systems` address and any [Miren Cloud subdomains](./subdomains.md) you've claimed. Bringing your own custom domain through Miren Anywhere isn't supported yet. For now, a custom domain needs a cluster that's directly reachable on the public internet.
**It carries app traffic, not deploys.** Miren Anywhere routes the HTTP and HTTPS your visitors load, on ports 80 and 443. It doesn't yet carry the control plane, so `miren deploy`, `miren logs`, and other CLI commands still need a direct network path to the cluster they're talking to. In practice that means a cluster with no public address can serve its apps to the whole internet through the POP network while you deploy to it from the same LAN. Carrying that traffic over Miren Anywhere too is on the roadmap.
:::info[Reading the Connectivity panel]
This split is why a cluster can show "Not reachable" for deploys yet still show its apps as available. See [Cluster Connectivity](./connectivity.md) for how the deploy path and the app path are checked separately.
:::
## Verify the connection
The clearest signal is in the server logs. Once the server is running, look for the Miren Anywhere connector reaching Cloud:
```text
component=anywhere ... connected to cloud
```
From [Miren Cloud](./overview.md), the [Connectivity](./connectivity.md) panel on your cluster's page reflects the same thing: when the live link is up, the cluster reads **Connected**, as opposed to merely **Online** (which only means it's still checking in).
## When something's wrong
:::warning[Your apps aren't reachable at all]
First, check that Miren Anywhere is actually on for the cluster. A cluster set to **Never**, or a non-containerized install still on its default, never routes through the POP network, so its hostnames aren't pointed there and nothing reaches them from outside, even though the connection to Cloud is perfectly healthy. Set the mode to **Auto** or **Always** on the cluster's page in Miren Cloud. See [when Anywhere carries your traffic](#when-anywhere-carries-your-traffic).
:::
:::note[The first request waits a beat]
The first request to a cluster that isn't connected to that POP yet is held open while the POP wakes your cluster and it dials in, usually a second or two, and then it's served normally. The POP only returns a short `503 Cluster connecting, retry shortly` if the cluster takes more than 15 seconds to connect. The connection also re-establishes itself automatically if it ever drops, so you don't need to restart the server to recover.
:::
:::warning[The connection never forms]
Miren Anywhere needs outbound connectivity from your cluster on two paths: a WebSocket to Miren Cloud for the control channel (standard HTTPS, TCP 443), and a QUIC connection to the Miren POP it's told to dial (UDP 8443). Strict egress filtering, or a network that blocks outbound UDP, will keep the tunnel from forming. Your server logs under `component=anywhere` show the POP address it's reaching for; a repeating `websocket disconnected, reconnecting` line means it's trying and failing, while `connected to cloud` means it's up.
:::
## Next steps
- [Subdomains](./subdomains.md) — Claim a hostname that routes through Miren Anywhere
- [Cluster Connectivity](./connectivity.md) — Read the three connectivity checks and what each one means
- [Miren Cloud Overview](./overview.md) — Cluster registration and login
---
## Miren Cloud
You install Miren on your own server and you own the whole thing. Run it standalone and it works great on its own, but it's an island: your apps are only reachable where your network reaches, backups are yours to arrange, and it's just you managing it.
Miren Cloud is the connective tissue you can opt into. It doesn't take over your infrastructure. It sits alongside your clusters and adds the things that are a pain to build yourself.
## What you get by connecting
**A public front door for your apps.** Claim a [subdomain](./subdomains.md) like `mycluster.run.garden` and your apps get a real address. With [Miren Anywhere](./miren-anywhere.md), they stay reachable from the internet even when your cluster has no public IP, so a home lab or a box behind NAT can serve real traffic.
**Your team.** Bring other people in and control who can do what with role-based access, instead of sharing one set of credentials.
**Multiple environments.** Run separate clusters for production, staging, and preview work under one account, and switch between them from the CLI.
## You still own everything
Connecting to Miren Cloud is additive, not lock-in. Miren runs fully standalone, and if you'd rather keep it that way you can install with cloud turned off:
```bash
sudo miren server install --without-cloud
```
A standalone cluster reaches the network however you've wired it, and none of the cloud features above apply. You can always connect later.
## Getting connected
When you run `miren server install`, Miren registers the cluster with Miren Cloud and walks you through creating your account. To connect a server you installed standalone, register it after the fact:
```bash
miren server register -n my-cluster
```
Then log in from whatever machine you work on:
```bash
miren login
```
The [Getting Started](../getting-started.md) guide covers the full install-and-first-deploy flow. For everything the CLI can do with clusters, see the [`miren cluster`](../command/cluster.md) reference.
## Disconnecting
Connecting is reversible. [`miren server unregister`](../command/server-unregister.md) is the inverse of registering: it removes the cluster from your organization in Miren Cloud, revokes the credentials it was using, and clears its registration locally.
```bash
sudo miren server unregister
```
Your apps and sandboxes keep running throughout. What goes away is everything the cluster got from being part of an organization: its cloud DNS records, Miren Anywhere routing, and cloud-managed access rules, which leaves certificate authentication as the way in. The cluster carries on as a standalone one, exactly as if you had installed it with `--without-cloud`.
This is also how you move a cluster between organizations. Unregister it, `miren login` against the organization you want, then register again. The cluster comes back as a new entry with a fresh identity rather than carrying its old one across, since a cluster record belongs to the organization it was created in.
:::warning[Apps served through Miren Cloud lose their address]
If your apps are reachable at a [subdomain](./subdomains.md) or through [Miren Anywhere](./miren-anywhere.md), that traffic stops once the cluster is unregistered. The apps are still running, but nothing routes to them until you give the cluster an address of its own.
:::
If the cluster entry in Miren Cloud is already gone, or the cluster can't reach the internet to say goodbye, `sudo miren server unregister --local-only` clears the local registration without contacting the cloud.
## Explore
- [Subdomains](./subdomains.md) — Claim a hostname like `mycluster.run.garden` for your apps
- [Miren Anywhere](./miren-anywhere.md) — Serve public apps from a cluster with no public IP
- [Cluster Connectivity](./connectivity.md) — Tell whether your cluster is online, deployable, and serving apps
- [Upgrades](./upgrades.md) — Upgrade a cluster from Miren Cloud, or keep it current automatically
---
## Subdomains
You've got a Miren cluster running your stuff — now give it an address people can actually visit. You can always [bring your own domain](../traffic-routing.md#custom-domains), but if you'd rather skip the DNS busywork, Miren Cloud lets you claim a subdomain like `mycluster.run.garden`, point it at your cluster, and you're live.
## Available Base Domains
You can claim subdomains under two base domains:
| Domain | Notes |
|--------|-------|
| `run.garden` | Great for running your [garden server](https://miren.dev/blog/garden-server) |
| `miren.app` | A clean, professional option for your projects |
Both come with wildcard DNS — once you claim `mycluster.run.garden`, requests to `*.mycluster.run.garden` are routed to your cluster too. That means each of your apps can get its own hostname without any extra setup.
## Claiming a Subdomain
Head to your organization in [miren.cloud](https://miren.cloud), find the **Subdomains** section, and pick a name.

Names are 3–63 characters — lowercase letters, numbers, and hyphens. A handful of common names like `www` and `api` are reserved.
## Assigning to a Cluster
Once you've claimed a subdomain, click on it and choose **Assign to Cluster**. Pick one of your active clusters and Miren takes care of the rest — CNAME and wildcard DNS records are provisioned automatically. Propagation usually takes a few minutes.
## Routing Traffic to Your App
With the subdomain assigned to your cluster, the last step is telling Miren which app should handle the traffic:
```bash
miren route set mycluster.run.garden myapp
```
You can also route wildcard subdomains to an app — handy if you want each app on its own sub-subdomain:
```bash
miren route set '*.mycluster.run.garden' myapp
```
See [Traffic Routing](../traffic-routing.md) for more on how routes work.
## Good to Know
**Wildcard DNS** — When you assign a subdomain, Miren provisions both the base name and a wildcard (`*.mycluster.run.garden`), so you can give every app its own hostname or build multi-tenant setups without touching DNS again.
**TLS certificates** — Miren provisions certificates automatically for your subdomains via [Let's Encrypt](../tls.md). This is especially relevant for `miren.app` subdomains, since the `.app` TLD requires HTTPS in all browsers.
**DNS propagation** — Records are provisioned on assignment and usually resolve within a few minutes, though in rare cases it can take up to an hour.
**Limits** — During Developer Preview, each organization can claim up to 10 subdomains.
## Next Steps
- [Traffic Routing](../traffic-routing.md) — Set up routes to direct traffic to your apps
- [TLS Certificates](../tls.md) — How Miren handles HTTPS
- [Miren Cloud Overview](./overview.md) — Cluster registration, login, and team management
---
## Upgrades from Miren Cloud
A registered cluster's page in Miren Cloud has a **Server** section. Its tiles show which version the cluster runs, whether a newer release is out, and how the last change went, and the upgrade button sits in the section's header. From there an organization admin can upgrade the cluster, and optionally let Miren Cloud keep it current on a schedule.
## Minimum working example
1. Open the cluster in Miren Cloud. In the **Server** section, the **Updates** tile says when a newer release is available.
2. Click the **Upgrade to** button in the section's header, which names the new version.
3. Watch the progress below the tiles. The cluster disconnects while its server restarts and reconnects on the new version.
## What an upgrade does
An upgrade from Cloud runs the same operation as `sudo miren upgrade` on the host. The server downloads the release, snapshots its data, installs the release, and restarts. It counts as done only when the new server reports ready on the new version. If that doesn't happen, the server puts the previous version and its data back on its own.
Once the server is on the new version, it upgrades any [distributed runners](../distributed-runners.md) one at a time. If the first runner fails, the rest are left alone.
The operation runs on the cluster, not in your browser or in Cloud. Progress on the page follows the cluster's own record of it, so closing or reloading the page is safe. Restarts and upgrades started on the host with `miren upgrade` show up in the same history.
## Requirements
- The cluster is [registered](./overview.md) with Miren Cloud and currently connected.
- It runs Miren v0.16.0 or later. Earlier releases can't take upgrades from Cloud, so upgrade those once on the host:
```miren
sudo miren upgrade
```
Each distributed runner needs the same one-time upgrade on its own host before the server can upgrade it for you.
## Automatic upgrades
Automatic upgrades are off until you turn them on. The **Automatic upgrades** row in the Server section takes a release channel and a maintenance window.
| Channel | What it installs |
| --- | --- |
| **Stable** (recommended) | A release once it has proven out, usually about a week after it ships. |
| **Latest** | Every release, on the first window after it ships. |
The **maintenance window** is when Cloud may start an upgrade: which days, what time it opens, and how long it stays open, in a timezone you pick. It defaults to 02:00 to 05:00 every day. Cloud starts at most one upgrade per release. If an automatic upgrade fails, Cloud doesn't try that release again; retry it with the upgrade button, or wait for the next release.
## Notifications
Organization admins can send upgrade outcomes to Slack from the **Notifications** section of the organization page. Add a channel with a Slack incoming webhook URL and use **Send test** to check it. Each finished operation posts one message: the version change on success, or the error and whether the cluster rolled back.
---
## Miren Club
Miren Club is our shared cloud cluster — a place to experiment with Miren and deploy something to the real internet without setting up a server of your own first. It's the fastest way to go from "I've heard of Miren" to "my app is live," and a great sandbox for demos, hack projects, and kicking the tires.
If you'd rather run Miren on your own machine, that's the [Getting Started](./getting-started.md) path. Miren Club is for when you just want a cluster to already exist.
## Requesting Access
Access is granted through our [Discord](https://miren.dev/discord). Here's the flow:
1. Sign in to [Miren Cloud](https://miren.cloud) with your GitHub or Google account.
2. Post in the **#miren-club** channel asking for access, with a sentence about what you'd like to deploy.
3. One of us will DM you an invite link.
Once you've accepted the invite, you're a member of the Miren Club organization in Miren Cloud and ready to connect.
## Connecting Your CLI
With access sorted, log in from your local machine:
```miren
miren login
```
Follow the prompts to authenticate with Miren Cloud. Then bind the club cluster to your local CLI:
```miren
miren cluster add
Select a cluster to bind:
NAME ORGANIZATION ADDRESS
▸ club Miren Club 34.27.122.56:8443 (+6)
```
Pick **club** and you're pointed at the shared cluster. Every `miren` command you run now targets Miren Club until you [switch clusters](./command/cluster.md).
## Deploying and Going Live
Deploying to Miren Club works exactly like deploying anywhere else. From your project directory:
```miren
miren deploy
```
The one perk that comes for free: the club owns the `miren.club` domain with wildcard DNS, so you can route your app to any name you like under it. Pick something and set the route:
```miren
miren route set whateveryoulike.miren.club myapp
```
That's it — `whateveryoulike.miren.club` is live on the internet, TLS and all. See [Traffic Routing](./traffic-routing.md) for more on routes.
## Club Rules
Miren Club is a shared resource, so a few ground rules keep it pleasant for everyone:
- **Don't hog the GBs or the MHz.** Other people are sharing this cluster with you. Be a considerate neighbor with memory and CPU.
- **Watch your step.** Right now there's a single namespace and no per-app permissions, so it's possible to clobber someone else's stuff. Deploy carefully and stay in your lane.
- **Have fun, but be responsible.** Anything that violates our [Code of Conduct](./conduct.md) gets removed, and you'll have to turn in your Miren Club membership card.
## Need Help?
- Questions about **Miren Club specifically** (access, the shared cluster, routing to `miren.club`)? Ask in **#miren-club** on [Discord](https://miren.dev/discord).
- Questions about **using Miren generally**? Ask in **#feedback**, or check [Troubleshooting](./troubleshooting.md).
## Next Steps
- [Getting Started](./getting-started.md) — The full deploy walkthrough, which works the same on Miren Club
- [Traffic Routing](./traffic-routing.md) — Custom domains, wildcards, and path-based routing
- [App Configuration](./app-configuration.md) — Configure your app with `.miren/app.toml`
---
## Observability
Miren can scrape a Prometheus-compatible metrics endpoint from every running
replica of an application service. It also instruments the request lifecycle
with [OpenTelemetry](https://opentelemetry.io/) distributed tracing.
## Managed application metrics
First, configure a remote-write destination for your cluster in
[`server.toml`](./server-config.md#managed-app-metrics). An application then opts a
service in through `.miren/app.toml`:
```toml
[services.web.metrics]
enabled = true
path = "/metrics"
port = 3000
interval = "30s"
```
Miren scrapes each running sandbox over its private overlay address and adds
`miren_app`, `miren_app_version`, `miren_service`, `miren_sandbox`,
`miren_runner`, and `miren_cluster` labels. Runtime labels take precedence over
labels emitted by the application, so a workload cannot impersonate another
replica or cluster. Registered clusters use their Miren Cloud cluster ID for
`miren_cluster`; other clusters use the configured cluster name.
The metrics path is private by default. Public requests to that exact path get
a 404 even when the app has a public route. Set `public = true` only when you
intend the app's normal public ingress and authentication policy to apply.
Miren sends these samples directly to the configured destination with a
short-lived workload identity token. It does not retain a second copy in the
cluster's embedded VictoriaMetrics instance.
The coordinator owns both scraping and remote write, so failures appear in the
managed scraper's system logs:
```bash
miren logs system vmagent --last 15m
```
This includes endpoint errors, invalid or oversized responses, authentication
failures, and delivery retries. `miren logs app` includes only output written by
the application itself.
### Runtime operational metrics
When a remote-write destination is configured, the coordinator also ships its
own operational series through the same pipeline: the control process's Go
heap, goroutine count and resident memory (`go_*`, `process_resident_memory_bytes`),
embedded etcd backend health (`etcd_db_size_bytes`, `etcd_nospace_alarm`,
`etcd_nospace_recovery_total`, and friends), host usage (`node_*`), and reconcile
controller queue depths (`reconcile_controller_*`). Shipped copies carry
`miren_cluster` and `miren_runner` so series pooled from many clusters stay
distinct. These series also remain in the cluster's embedded VictoriaMetrics,
unlabeled, where the node is implicit.
There is no separate switch. Configuring the destination turns on application
metrics and runtime metrics together, and delivery failures for both appear in
the same `miren logs system vmagent` output.
## Distributed tracing
### Minimum working example
Miren's side needs no setup — every request is already traced, and the `traceparent` header is forwarded to your app. To join your app's spans to those traces, point an OpenTelemetry SDK at your backend in `.miren/app.toml`:
```toml
[[env]]
key = "OTEL_EXPORTER_OTLP_ENDPOINT"
value = "https://your-otel-collector:4318"
[[env]]
key = "OTEL_SERVICE_NAME"
value = "my-app"
```
The SDK picks up `traceparent` from incoming requests, so your app's spans land in the same trace as Miren's routing and cold-start spans.
### What Miren Traces
Every HTTP request that arrives at Miren generates a trace with spans covering:
- **httpingress** — The full request lifecycle: routing, lease acquisition, and proxying to your app
- **httpingress.lease** — Sandbox lease management, including whether a cached lease was used or a cold start was required
- **RPC calls** — Internal service-to-service communication within Miren
- **containerd gRPC** — Container operations like image pulls, container creation, and task management
- **BuildKit** — The embedded build daemon's own spans for each deploy's image build: each solve step, cache lookups, and the image export
The most useful spans for app developers are `httpingress` (overall request latency) and `httpingress.lease` (cold start visibility). The RPC, containerd, and BuildKit spans are primarily useful for operators debugging Miren itself.
### Configuring Miren's own export
Operators turn on Miren's trace export by setting `OTEL_EXPORTER_OTLP_ENDPOINT` (and `OTEL_EXPORTER_OTLP_HEADERS` if the collector needs credentials) in the environment of the `miren server` process. Miren always exports over OTLP/HTTP (`http/protobuf`), and it hands the embedded BuildKit daemon the same endpoint with the protocol pinned to match, so the collector only needs to accept HTTP. Setting `OTEL_EXPORTER_OTLP_PROTOCOL` yourself overrides that pin for BuildKit, but Miren's own exporter stays on HTTP regardless, so a gRPC-only collector will never see Miren's spans.
:::note[BuildKit metrics are off by default]
BuildKit can also push its own OTLP metrics, but most trace backends do not accept them, so Miren disables that exporter unless you opt in. Set `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, or `OTEL_METRICS_EXPORTER=otlp`, if you have somewhere to send them.
:::
### Trace Context Propagation
Miren participates in [W3C Trace Context](https://www.w3.org/TR/trace-context/) propagation in both directions:
**Inbound:** If your request includes a `traceparent` header, Miren continues that trace rather than starting a new one. This means requests from an instrumented frontend or upstream service produce a single connected trace that includes Miren's processing.
**Outbound:** When Miren forwards a request to your app, it injects a `traceparent` header. Your app can pick this up to create child spans that appear in the same trace as the Miren infrastructure spans.
### Connecting Your App's Traces
Miren's tracing and your app's tracing are configured independently — Miren handles its own trace export, and your app handles its own. The `traceparent` header is what connects them: when both sides send traces to the same backend, they show up as one unified trace because they share the same trace ID.
To participate, add an OpenTelemetry SDK to your app and point it at your OTLP-compatible backend. The SDK will automatically read the `traceparent` header from incoming requests and create child spans.
Set these environment variables on your app in `.miren/app.toml`:
```toml
[[env]]
key = "OTEL_EXPORTER_OTLP_ENDPOINT"
value = "https://your-otel-collector:4318"
[[env]]
key = "OTEL_EXPORTER_OTLP_HEADERS"
value = "Authorization=Bearer your-api-key"
[[env]]
key = "OTEL_SERVICE_NAME"
value = "my-app"
```
This works with any OTel-compatible backend: Grafana Tempo, Honeycomb, Datadog, Jaeger, and others. You can use the same backend as your Miren cluster or a different one — as long as traces with the same trace ID end up in the same place, they'll be correlated.
### Python Example
Using the OpenTelemetry auto-instrumentation for Flask:
```bash
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
```
```toml
[[env]]
key = "OTEL_EXPORTER_OTLP_ENDPOINT"
value = "https://your-otel-collector:4318"
[[env]]
key = "OTEL_SERVICE_NAME"
value = "my-flask-app"
```
```text
# Procfile
web: opentelemetry-instrument flask run --host 0.0.0.0 --port 3000
```
The `opentelemetry-instrument` wrapper automatically reads the `traceparent` header from incoming requests and creates spans for your Flask routes.
### Node.js Example
Using the OpenTelemetry auto-instrumentation for Node.js:
```bash
npm install @opentelemetry/auto-instrumentations-node
```
```toml
[[env]]
key = "OTEL_EXPORTER_OTLP_ENDPOINT"
value = "https://your-otel-collector:4318"
[[env]]
key = "OTEL_SERVICE_NAME"
value = "my-node-app"
[[env]]
key = "NODE_OPTIONS"
value = "--require @opentelemetry/auto-instrumentations-node/register"
```
The `--require` flag loads the auto-instrumentation before your app starts, automatically instrumenting HTTP, Express, and other common libraries.
### What a Trace Looks Like
A typical request trace shows the full path through Miren:
```text
httpingress [350ms]
├─ httpingress.lease [200ms] (cold start)
│ ├─ rpc.call.AcquireLease [195ms]
│ │ ├─ containerd...Images/Pull [150ms]
│ │ └─ containerd...Tasks/Create [40ms]
├─ [proxy to app] [150ms]
│ └─ my-app: GET /api/users [145ms] (your app's span)
```
On a warm request where a sandbox is already running:
```text
httpingress [15ms]
├─ httpingress.lease [0.1ms] (cached lease)
├─ [proxy to app] [14ms]
│ └─ my-app: GET /api/users [12ms]
```
## Next Steps
- [Logs](./logs.md) — View and filter application, build, and system logs
- [Services](./services.md) — Configure your app's services
- [Application Scaling](./scaling.md) — Understand cold starts and autoscaling
---
## Pull Request Environments
A pull request environment is a labeled build of your app — called an **ephemeral version** in Miren — that runs alongside the active version on its own subdomain. It runs separately from your normal deploys and is deleted automatically when its TTL expires. The typical use is one preview per PR, reachable at something like `pr-123.myapp.example.com`.
## Minimum working example
With wildcard DNS pointed at your cluster (`*.myapp.example.com` → your cluster's hostname), one flag creates a preview:
```miren
miren deploy --ephemeral pr-123 --ttl 48h
```
The build comes up at `https://pr-123.myapp.example.com`, runs alongside the active version without touching production traffic, and deletes itself when the TTL expires. See [Quick Start](#quick-start) for the DNS setup.
## How It Works
When you run `miren deploy --ephemeral `:
1. Miren builds the version but doesn't activate it. The active version keeps serving production traffic.
2. The new version is reachable at `.`. A request for any subdomain of an existing route is looked up against ephemeral labels for that route — no separate route entity needed.
3. After the TTL elapses (default 24 hours), a background controller deletes the version.
Ephemeral deploys don't create deployment history records, don't take the deployment lock, and don't block normal deploys.
## Quick Start
**Step 1: Point DNS at your cluster for the subdomains you'll use.** A wildcard CNAME is the usual choice:
```text
*.myapp.example.com. CNAME cluster-jwomf2l0tn8z.miren.systems.
```
You don't need to configure a wildcard route on your server. Any existing route for `myapp.example.com` will pick up `pr-123.myapp.example.com` as an ephemeral lookup. See [Custom Domains](./traffic-routing.md#custom-domains) for the full DNS setup.
**Step 2: Deploy with `--ephemeral` and an optional TTL.**
```miren
miren deploy --ephemeral pr-123 --ttl 48h
```
Miren builds the version and prints the access URL:
```text
Ephemeral version myapp-vXYZ created.
Label: pr-123
TTL: 48h
URL: https://pr-123.myapp.example.com
```
Open the URL — your preview is live. TLS provisions on first request.
## What Runs in an Ephemeral Version
:::warning[Only the web service runs]
Workers, background jobs, scheduled tasks, and any other services defined in `.miren/app.toml` aren't started for ephemeral versions — HTTP traffic to the subdomain is the only thing wired up. If reviewing your PR requires a worker too, use a separate staging app instead.
:::
:::warning[Configuration is shared with the active version]
Env vars, secrets, addons, and build config all come from the app's current settings. There's no per-ephemeral override:
- Your preview connects to the same database, queues, and external services as production.
- You can't change `RAILS_ENV`, feature flags, or service URLs just for the preview.
- Updates to app config (via `miren config set`, addons, etc.) affect both active and ephemeral versions.
:::
If you need isolation from production data, run ephemeral deploys against a separate staging app.
## Using a Staging App
Because ephemeral versions share config with the active version, a preview deployed against your production app talks to your production database, queues, and external services. That's fine for read-mostly UI changes, but risky as soon as a PR writes data, runs migrations, or fires background jobs.
Set up a second app — typically `myapp-staging` — that points at a staging database and any other backing services you want isolated, then run all PR previews against that app instead of production.
**Step 1: Create the staging app.** Deploy your main branch to it with whatever staging-specific config you want:
```miren
miren deploy -a myapp-staging \
-e DATABASE_URL=postgres://staging-db.internal/myapp \
-e RAILS_ENV=staging
```
**Step 2: Add a route for the staging app and wildcard DNS for its subdomains.**
```miren
miren route set staging.myapp.example.com myapp-staging
```
```text
*.staging.myapp.example.com. CNAME cluster-jwomf2l0tn8z.miren.systems.
```
**Step 3: Run PR previews against the staging app.**
```miren
miren deploy -a myapp-staging --ephemeral pr-123 --ttl 48h
```
The preview is reachable at `pr-123.staging.myapp.example.com`, isolated from production data. Redeploy the staging app's active version periodically (or on every push to `main`) to keep its baseline fresh — ephemeral previews inherit the staging app's current config and addons, not production's.
In CI, set `MIREN_APP=myapp-staging` (or pass `app: myapp-staging` to the deploy action) so PR workflows always target staging.
### Using a Staging Cluster
Another pattern is to setup a separate staging cluster that you deploy the PRs to, rather than your production cluster. This lets you isolate the preview even more.
## Labels
Labels are used as DNS subdomains, so they must be DNS-compliant (RFC 1123):
- Lowercase alphanumeric characters and hyphens only
- Must start and end with an alphanumeric character
- Max 63 characters
Miren normalizes common separators for you — underscores, slashes, and dots become hyphens, uppercase is lowercased, and other characters are stripped:
| Input | Normalized |
|-------|------------|
| `feat/login` | `feat-login` |
| `My_Branch.v2` | `my-branch-v2` |
| `PR-123` | `pr-123` |
So a Git branch name usually works as-is:
```miren
miren deploy --ephemeral "$(git rev-parse --abbrev-ref HEAD)"
```
:::info[Redeploying with the same label replaces the prior version]
Push a new commit, redeploy with `--ephemeral pr-123`, and the previous `pr-123` version is deleted before the new one becomes reachable. The TTL resets on each deploy.
:::
## TTL and Cleanup
`--ttl` takes a Go duration string (`30m`, `2h`, `48h`) and defaults to `24h`. Expiration is fixed at deploy time.
Requests to an expired label return 404 immediately — the ephemeral lookup filters expired versions itself, so the cutoff is enforced as soon as the timestamp passes. A background controller sweeps the actual entities every five minutes to free their resources. There's no extend command — redeploy with the same label to refresh the TTL.
**Per-app limit.** Each app can have at most 10 ephemeral versions at once. Deploying an 11th evicts the version nearest to expiry. Replacing an existing label doesn't count against the limit, since the old version is deleted before the new one is created.
## Listing Ephemeral Versions
Show only ephemeral versions for an app:
```miren
miren app versions --ephemeral
```
```text
VERSION LABEL CREATED EXPIRES
myapp-vCVkjR6u7744AsMebwMjGU pr-123 2m ago 2026-05-28 14:00:00
myapp-vCVkjJSe4fydvxEHfhsKfA pr-118 3h ago 2026-05-28 11:30:00
```
`--format json` is supported for scripting. Drop `--ephemeral` to see all versions, with ephemeral ones marked.
Ephemeral deploys don't appear in `miren app history` — that command shows only tracked deployments of the active version.
## GitHub Actions: Per-PR Previews
To deploy a preview per pull request from GitHub Actions, pair this with [CI/CD Deployment with OIDC](./ci-deploy.md) so no secrets land in your repo. The example below targets a staging app — see [Using a Staging App](#using-a-staging-app) for why that's the recommended setup.
**Step 1: Allow `pull_request` events on the OIDC binding.**
`miren auth ci add --github` permits `push` and `workflow_dispatch` by default. Add `pull_request`:
```miren
miren auth ci add -a myapp-staging --github acme/web-app \
--allowed-events push,workflow_dispatch,pull_request
```
**Step 2: Add the workflow.**
```yaml
name: PR Preview
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
id-token: write
contents: read
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy preview
id: deploy
uses: mirendev/actions/deploy@main
with:
cluster: ${{ secrets.MIREN_CLUSTER }}
app: myapp-staging
ephemeral: pr-${{ github.event.pull_request.number }}
ttl: 48h
```
Each push replaces the previous preview at the same label. When the PR is merged or closed, the version expires on its own — no teardown step needed.
The deploy action exposes the preview URL as a step output, so a follow-up step can post it as a PR comment:
```yaml
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
const url = '${{ steps.deploy.outputs.url }}';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployed: ${url}`,
});
```
## Limitations
- **`web` service only** — workers and other services from your app config don't start (see [What Runs in an Ephemeral Version](#what-runs-in-an-ephemeral-version)).
- **Shared configuration** — env vars, secrets, and addons all come from the app. No per-preview overrides.
- **No deployment history** — `miren app history`, `miren rollback`, and the deployment lock all ignore ephemeral deploys.
- **10 per app** — older versions are evicted by expiry as new ones arrive.
- **DNS must cover the subdomains** — without a wildcard CNAME (or per-label records) pointing at your cluster, the URL won't resolve.
## Command Reference
The flags introduced on this page are `--ephemeral` and `--ttl` on [`miren deploy`](./command/deploy.md), and `--ephemeral` on [`miren app versions`](./command/app-versions.md). Those reference pages have the full flag listings.
## Next Steps
- [Deployment](./deployment.md) — How normal deploys work
- [Traffic Routing](./traffic-routing.md) — Routes, wildcard DNS, and custom domains
- [CI/CD Deployment with OIDC](./ci-deploy.md) — Deploy from GitHub Actions without stored secrets
- [TLS Certificates](./tls.md) — How HTTPS works for ephemeral subdomains
---
## Run an Amp agent on Miren
Run [Amp](https://ampcode.com), a coding agent, as a headless **runner** on a
Miren cluster. The container dials *out* to ampcode.com, registers as a Machine, and then
waits. You create threads from the ampcode.com web app, pick this runner, and the work
executes on Miren — streaming back to your browser.
The end state: a single portless Miren service running `amp --no-tui`, connected to
ampcode.com with an API key, with its working directory on a persistent Miren disk. There is
**no inbound port, no dashboard, and no HTTP ingress** — ampcode.com is the control plane and
the runner only makes outbound connections.
Along the way it exercises real Miren behavior — custom Dockerfile builds, a **portless
background service**, `0.0.0.0`-free networking (nothing listens), a writable network disk,
and single-instance concurrency — so it doubles as a tour of running an outbound-only worker.
:::info[This is an application recipe, not a language guide]
For getting your own source code onto Miren, start with [Deployment](../deployment.md) and the
[Language Guides](../guides/index.md). This page is about running a third-party agent as an
outbound-only service.
:::
## How it works
1. The Miren service runs `amp --no-tui --runner-id ` as a long-lived process.
2. On start it reads `AMP_API_KEY` and dials ampcode.com, registering as runner ``.
3. Its `settings.json` has `amp.remoteThreadCreation.enabled: true`, so it accepts threads
created remotely.
4. From the ampcode.com web app you create a thread on that runner. It runs in the runner's
working directory (`/workspace` on the disk) and streams output back to ampcode.com — and
to `miren logs`.
Because a runner is identified by **host + working directory**, the persistent disk keeps its
workspace (cloned repos, build caches) stable across redeploys.
## Prerequisites
- `miren` CLI installed and authenticated (`miren whoami`).
- Access to the target cluster and its org.
- An **Amp API key** from [ampcode.com/settings](https://ampcode.com/settings).
- Outbound HTTPS from the cluster to Amp's domains (see [Firewall](../firewall.md) if you restrict
egress): `ampcode.com` (service + installer), `auth.ampcode.com` (authentication),
`production.ampworkers.com` (the runner's WebSocket connection), and `static.ampcode.com`
(binary downloads for install and `amp update`). A narrower allowlist can let the build or
runner start while authentication or registration silently fails.
## Select the target cluster
If the cluster isn't configured locally yet, list what your identity can see and add it
(this pins the TLS fingerprint):
```bash
# Add a cluster your cloud identity can see (interactive picker; pins the TLS fingerprint)
miren cluster add -i cloud
miren whoami -C amp
```
The commands below target it explicitly with `-C amp`. Omit `-C` to use your default cluster.
## The Dockerfile
Keep a Dockerfile here because Amp ships as a CLI, not a container image. This is a real
application image: it pins and installs Amp, adds the shell tools the agent needs, and copies
the runner lifecycle script. The official installer produces a standalone binary, so Node
isn't required.
```dockerfile
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates ripgrep curl \
&& rm -rf /var/lib/apt/lists/*
# Install the Amp CLI to a fixed location and put it on PATH. AMP_VERSION pins the
# release for reproducible builds; drop it to track latest, or bump it deliberately.
ENV AMP_HOME=/opt/amp
RUN curl -fsSL https://ampcode.com/install.sh | AMP_VERSION=0.0.1784319456-g6a2cfc bash \
&& ln -s /opt/amp/bin/amp /usr/local/bin/amp
COPY runner.sh /usr/local/bin/runner.sh
RUN chmod +x /usr/local/bin/runner.sh
```
:::note[Why the extra symlink]
The installer drops the binary at `$AMP_HOME/bin/amp` and tries to symlink it into a
per-user PATH directory (`~/.local/bin`). Building as root, that isn't reliably on `PATH`, so
the recipe symlinks `/opt/amp/bin/amp` into `/usr/local/bin` explicitly.
:::
:::note[No ENTRYPOINT or CMD needed]
Miren runs the service `command` from `app.toml` via `/bin/sh -c ""`, so this image
doesn't need an `ENTRYPOINT` or `CMD` — `runner.sh` is invoked directly (see below).
:::
## The runner entrypoint
`runner.sh` writes Amp's settings on the disk (enabling remote thread creation so ampcode.com
can dispatch threads to it — without this the runner connects but silently accepts nothing),
clears any stale runner lock left on the disk by a prior deploy, then hands off to the runner
with `exec` so signals reach Amp and Miren can stop it cleanly:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Working directory for the runner. Override with AMP_WORKSPACE (in app.toml [[env]]) if you
# change the disk mount_path.
WORKSPACE="${AMP_WORKSPACE:-/workspace}"
mkdir -p "$HOME" "$WORKSPACE" "$(dirname "$AMP_SETTINGS_FILE")"
# Enable remote thread creation so ampcode.com can dispatch threads to this runner. Rewritten
# on every boot (not just when absent) so a stale settings file left on the persistent disk
# can't silently keep the setting off after you change this recipe. Edit the keys to reconfigure.
cat > "$AMP_SETTINGS_FILE" <<'JSON'
{
"amp.remoteThreadCreation.enabled": true,
"amp.notifications.enabled": false
}
JSON
# Amp records a per-directory runner lock (a pidfile under its cache). With HOME on a
# persistent disk that survives redeploys, a stale pidfile makes the new runner refuse to
# start ("Another Amp process is already serving remote threads"). The Miren disk lease
# guarantees only one runner container at a time, so any pidfile here at boot is stale.
rm -f "$HOME/.cache/amp/pids/"*.pid 2>/dev/null || true
# Optional: seed a repo for the agent to work in. Adapt or remove.
# if [ ! -d "$WORKSPACE/repo/.git" ]; then
# git clone https://github.com/you/your-repo "$WORKSPACE/repo"
# fi
cd "$WORKSPACE"
exec amp --no-tui --runner-id "$AMP_RUNNER_ID"
```
## The app.toml
This file lives at `.miren/app.toml` in your project (not the repo root). If you haven't set
the project up yet, `miren init` creates it for you; otherwise create the `.miren/` directory
and add the file below. The `Dockerfile` and `runner.sh` stay at the project root.
```toml
name = "amp-runner"
[build]
dockerfile = "Dockerfile"
# Portless background service: nothing listens, so no `port`/`port_type`.
[services.runner]
command = "runner.sh"
[services.runner.concurrency]
mode = "fixed"
num_instances = 1
[[services.runner.disks]]
name = "workspace"
provider = "miren"
mount_path = "/workspace"
size_gb = 20
filesystem = "ext4"
# A stable, valid hostname, shown as the Machine name on ampcode.com. A runner is identified
# by host + working directory, so keep this fixed to make it easy to pick in the web app.
[[env]]
key = "AMP_RUNNER_ID"
value = "miren-amp"
# Keep Amp's settings, cache, and logs on the writable disk. Amp otherwise reads/writes
# ~/.config/amp, which lands on a read-only image path.
[[env]]
key = "AMP_SETTINGS_FILE"
value = "/workspace/amp/settings.json"
[[env]]
key = "HOME"
value = "/workspace/home"
[[env]]
key = "AMP_LOG_LEVEL"
value = "info"
[[env]]
key = "AMP_API_KEY"
required = true
sensitive = true
```
`amp --no-tui` is a long-lived daemon (unlike `amp -x`, which runs one prompt and exits): Miren
restarts it if it exits, and the runner re-registers with ampcode.com after a bounce (it may
briefly show as a new Machine connection before the old one drops).
:::warning[Portless service — name it `runner`, not `web`]
This service has no `port`/`port_type`, so Miren does no HTTP ingress and no port health
check. Do **not** name it `web`: Miren's ingress routes an app's hostname to the `web`
service, and a portless `web` service returns `error acquiring lease: app/amp-runner`
(HTTP 500) for every request. Any other name (like `runner`) is fine.
:::
:::danger[Constrain the agent — it runs arbitrary shell]
A runner executes agent-driven shell commands as the container user with full access to
`/workspace`. Use a dedicated Amp API key (revocable independently), treat the workspace as
untrusted, and consider Amp's `amp.commands.allowlist` / `amp.commands.strict` settings to
limit what it can run unattended.
:::
:::warning[A Miren disk forces single-instance, churny rollouts]
A `provider = "miren"` (network) disk holds one exclusive lease, so it requires
`concurrency mode = "fixed"` with `num_instances = 1`. On redeploy the new instance can't
mount `/workspace` until the old one releases the lease, so `miren deploy` may print
`did not become healthy` while the instance actually comes up a few seconds later — re-check
`miren app status` / `miren sandbox list` before assuming failure. If you'd rather have snappy
rollouts and don't need node-independent storage, use `provider = "local"` (no lease, no
fixed-instance requirement; workspace is node-local).
:::
:::warning[Redeploys leave a stale runner lock on the disk]
Amp writes a per-directory runner lock — a pidfile under `$HOME/.cache/amp/pids/`. Because
`HOME` is on the persistent disk, that pidfile survives a redeploy, and the fresh runner
refuses to start with `Another Amp process is already serving remote threads for /workspace`.
`runner.sh` deletes stale pidfiles on boot before launching Amp; the disk's single-instance
lease guarantees no live runner is ever using them.
:::
Add a `.dockerignore` so local secrets and Miren state stay out of the build context (secrets
are passed with `miren deploy -s`, never baked into the image):
```text
.env
.miren
```
## Deploy
Non-secret config lives in `app.toml`. Pass the API key with `-s` (masked in output, stored
server-side); never bake it into the image:
```bash
miren deploy -a amp-runner -C amp -f \
-s "AMP_API_KEY=sgamp_..."
```
Validate the config without building at any time:
```bash
miren deploy --analyze -a amp-runner -C amp
```
## Verify
```bash
miren app status -a amp-runner -C amp # Current Version + active
miren sandbox list -C amp # one running sandbox, service "runner"
miren logs -a amp-runner -C amp --since 5m # look for the runner connecting to ampcode.com
```
Then open [ampcode.com](https://ampcode.com): the Machine `miren-amp` should appear as a
connected runner. Create a thread on it, send a prompt, and confirm the work executes — output
streams in the ampcode.com web UI and in `miren logs`.
## Using the runner
- **Create work from ampcode.com:** start a thread targeting the `miren-amp` Machine. It runs
in `/workspace` on the cluster.
- **Pre-seed a repo:** clone into the workspace once, and it persists on the disk:
```bash
miren sandbox list -C amp # grab the sandbox id
miren sandbox exec -C amp -- \
git clone https://github.com/you/your-repo /workspace/repo
```
- **Rotate the key:** deploy again with a new `-s "AMP_API_KEY=..."`; the runner reconnects on
restart.
## Roadblock checklist
1. Set `amp.remoteThreadCreation.enabled: true` (in `runner.sh`), or the runner accepts no threads.
2. Portless service: omit `port`/`port_type`, and name it `runner`, not `web` (else `error acquiring lease`).
3. Run the daemon `amp --no-tui`, not `amp -x` — the service command must not exit.
4. Give it a stable `AMP_RUNNER_ID` (valid hostname) and a fixed working directory.
5. A `miren` disk means `fixed` / `num_instances = 1`; expect `did not become healthy` noise on redeploy (verify state before retrying). Use `local` for snappy rollouts.
6. Point `HOME` and `AMP_SETTINGS_FILE` at the writable disk.
7. Clear Amp's stale runner pidfile on boot (in `runner.sh`), or redeploys crash-loop with `Another Amp process is already serving remote threads`.
8. The agent runs arbitrary shell — use a dedicated key and constrain it.
## Next steps
- [App Configuration](../app-configuration.md) — the full `app.toml` reference in context
- [Persistent Storage](../disks.md) — Miren disks vs. local disks
- [Deployment](../deployment.md) — deploying your own source to Miren
- [Firewall](../firewall.md) — controlling egress if you restrict outbound traffic
- [Using Dockerfile.miren](../guides/index.md#using-dockerfilemiren) — building from your own Dockerfile
---
## Run a headscale control server
[headscale](https://headscale.net) is an open-source implementation of the Tailscale
control server — the coordination plane your Tailscale clients log in to, exchange keys
through, and get their network map from. Self-hosting it means your tailnet's coordination
belongs to you.
By the end you'll have headscale answering over HTTPS at a hostname you own, its database
and keys on a disk that survives redeploys, and a first node joined to your tailnet.
:::info[This is an application recipe, not a language guide]
For getting your own source code onto Miren, start with [Deployment](../deployment.md) and the
[Language Guides](../guides/index.md). This page is about self-hosting a prebuilt third-party server.
For the opposite topic — running a Miren **cluster** on a tailnet — see
[Running Miren on a Tailnet](../tailscale.md).
:::
## Prerequisites
- `miren` CLI installed and authenticated (`miren whoami`).
- Access to the target cluster and its org.
- A hostname you control, pointed at the cluster — see
[Custom Domains](../traffic-routing.md#custom-domains) or claim one through
[Miren Cloud subdomains](../miren-cloud/subdomains.md).
- **The ability to edit the cluster's server config.** Tailscale clients hold a connection
to the control server open far longer than the ingress allows by default, so this recipe
needs one cluster-wide setting changed before a client will stay connected. Details in
[Give clients a longer timeout](#longer-timeout).
:::warning[That timeout is cluster-wide]
`http_request_timeout` applies to every app on the cluster, not just headscale. If you can't
change it — someone else runs the cluster, or other apps depend on the current value — nodes
will keep dropping their connection to headscale, and there's no way to work around it from
headscale's side.
:::
## Select the target cluster
```bash
# Add a cluster your cloud identity can see (interactive picker; pins the TLS fingerprint)
miren cluster add
miren whoami
```
## The Dockerfile
Keep a small Dockerfile here. This is a derived image, not a wrapper: the deployment needs
to add `config.yaml`, and the upstream image supplies an `ENTRYPOINT` but no `CMD`.
```dockerfile
FROM docker.io/headscale/headscale:0.29.3
COPY config.yaml /etc/headscale/config.yaml
# The image sets an ENTRYPOINT but no CMD, so give it one.
CMD ["serve"]
```
That's the whole build. Miren still inherits the upstream entrypoint and working directory;
the Dockerfile adds the deployment artifact and completes the image's startup contract. The
upstream image already carries the CA bundle headscale needs to fetch the DERP map, so
there's nothing to install.
:::warning[Don't set a `command` for this app]
The headscale image ships without a shell, and a service that sets `command` needs one.
Leave it out — as the `app.toml` below does — and the image runs what its own `CMD` says.
This also means you can't open an interactive shell in the container; see
[Getting a shell](#getting-a-shell) if you want that.
:::
Add a `.dockerignore` so local files stay out of the build context:
```text
.env
.miren
```
## The config.yaml
This sits next to the Dockerfile and gets baked into the image. Everything that varies per
deployment is overridden by an environment variable, so the file itself is the same for
everyone:
```yaml
# Overridden by HEADSCALE_SERVER_URL from app.toml.
server_url: http://127.0.0.1:8080
# Bind on all interfaces. A 127.0.0.1 listener is unreachable from outside the
# container, so the app never comes up healthy.
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
# State on the mounted disk. headscale creates both on first start.
noise:
private_key_path: /data/noise_private.key
database:
type: sqlite
sqlite:
path: /data/db.sqlite
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
# Relay through Tailscale's public DERP servers.
derp:
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h
dns:
magic_dns: true
# Your server_url host must not sit underneath this domain.
base_domain: ts.example.com
override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
```
Any of these can move into `app.toml` as an environment variable instead: headscale accepts
`HEADSCALE_*` overrides for nested keys too, so `database.sqlite.path` becomes
`HEADSCALE_DATABASE_SQLITE_PATH`.
:::warning[A missing `dns` block fails validation]
`dns.override_local_dns` defaults to true, and headscale refuses to start without
nameservers to go with it:
`Fatal config error: dns.nameservers.global must be set when dns.override_local_dns is true`.
Either set `override_local_dns: false` as above, or supply `dns.nameservers.global`.
:::
## The app.toml
This file lives at `.miren/app.toml`.
```toml
name = "headscale"
[build]
dockerfile = "Dockerfile"
# Must be named `web` — that's the service a hostname route points at.
# No `command` here, on purpose: see above.
[services.web]
port_timeout = "120s"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
[[services.web.ports]]
port = 8080
name = "http"
type = "http"
# The database, the noise key, and (if you enable it) the DERP key.
[[services.web.disks]]
name = "state"
provider = "local"
mount_path = "/data"
# The public URL clients log in to. This must match the routed hostname exactly.
[[env]]
key = "HEADSCALE_SERVER_URL"
value = "https://headscale.example.com"
```
A control server shouldn't scale to zero or run two copies against one SQLite file, hence a
single fixed instance. [Persistent Storage](../disks.md) recommends a local disk for SQLite,
which is what this uses; note that any disk pins the app to the coordinator node.
## Deploy
```bash
# Validate the config without building
miren deploy --analyze -a headscale
miren deploy -a headscale -f
```
## Add a hostname route
The route target must match `HEADSCALE_SERVER_URL` exactly — clients are handed that URL
and will use it for every subsequent request.
```bash
miren route set headscale.example.com headscale
miren route list
```
## Give clients a longer timeout {#longer-timeout}
Do this before you connect a client. Tailscale clients keep a connection to the control
server open and idle for long stretches, longer than the ingress tolerates by default, and
headscale offers no setting to make them chattier. Left alone, nodes drop and reconnect for
no visible reason.
Raise the limit in the server config file (`/etc/miren/server.toml`, or
`/var/lib/miren/config/server.toml`) and restart the server:
```toml
[server]
http_request_timeout = 120
```
The value is seconds. See [Server Configuration](../server-config.md#server).
## Verify
```bash
miren app status -a headscale # Current Version + active
miren sandbox list # a running sandbox for headscale, service "web"
curl -fsS https://headscale.example.com/health # {"status":"pass"}
```
:::note[One warning in the logs is expected]
`miren logs` will show
`WRN listening without TLS but ServerURL does not start with http://`. That's correct here:
TLS terminates at Miren's ingress and headscale itself serves plain HTTP behind it, while
`server_url` is properly `https://`. Nothing is wrong.
:::
## Create a user and register a node
The `headscale` command only talks to its own running server, so these have to run inside
the live container. Find the sandbox in `miren sandbox list` — the one whose app is
`headscale` and service is `web` — and use its ID:
```bash
miren sandbox list # find the headscale web sandbox
miren sandbox exec -- headscale users create alice
miren sandbox exec -- headscale users list # note alice's numeric ID
miren sandbox exec -- headscale preauthkeys create --user --expiration 24h
miren sandbox exec -- headscale nodes list
```
`preauthkeys create` takes the user's numeric ID, not the name, which is why you look it up
first. On a cluster running other apps, `miren sandbox list` will show their sandboxes too —
match on the app name rather than taking the first row.
:::warning[`miren app run` won't work here]
It starts a fresh, separate container rather than reaching the one serving traffic, so the
`headscale` command inside it has no server to talk to. Use `miren sandbox exec`.
:::
Then, on the machine joining the tailnet, point Tailscale at your server and use the preauth
key:
```bash
tailscale up --login-server=https://headscale.example.com --authkey=
```
## Getting a shell {#getting-a-shell}
If you'd rather be able to poke around inside the container, build on a base that has a
shell instead. headscale's binary is statically linked, so it lifts out cleanly:
```dockerfile
FROM docker.io/headscale/headscale:0.29.3 AS upstream
FROM debian:bookworm-slim
# bookworm-slim ships no CA bundle, and headscale needs one for the DERP map.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=upstream /ko-app/headscale /usr/local/bin/headscale
COPY config.yaml /etc/headscale/config.yaml
```
Now that there's a shell, set the command explicitly:
```toml
[services.web]
command = "exec headscale serve"
port_timeout = "120s"
```
Everything else in the recipe is unchanged, and `miren sandbox exec ` opens a prompt.
The tradeoff is a larger image and a base you're responsible for patching.
## Running the embedded DERP relay
Tailscale clients prefer direct peer-to-peer connections and fall back to a DERP relay when
they can't get one. The config above borrows Tailscale's public relays, which is the simpler
choice and costs you nothing to operate.
To relay through your own server instead, enable headscale's embedded DERP. The relay is
served over HTTPS on your existing hostname; STUN additionally needs a UDP port open on the
host, declared as a [node port](../traffic-routing.md#non-http-services-tcpudp).
Add the STUN port alongside the HTTP one, and turn the relay on:
```toml
# A second port on the same service; the HTTP one above stays as it is.
[[services.web.ports]]
port = 3478
name = "stun"
type = "udp"
node_port = 3478
[[env]]
key = "HEADSCALE_DERP_SERVER_ENABLED"
value = "true"
[[env]]
key = "HEADSCALE_DERP_SERVER_REGION_ID"
value = "999"
[[env]]
key = "HEADSCALE_DERP_SERVER_REGION_CODE"
value = "miren"
[[env]]
key = "HEADSCALE_DERP_SERVER_REGION_NAME"
value = "Miren embedded DERP"
[[env]]
key = "HEADSCALE_DERP_SERVER_STUN_LISTEN_ADDR"
value = "0.0.0.0:3478"
[[env]]
key = "HEADSCALE_DERP_SERVER_PRIVATE_KEY_PATH"
value = "/data/derp_server_private.key"
```
On boot headscale logs `stun server started at [::]:3478` and advertises the new region.
Open 3478/udp in any cloud firewall in front of the cluster — node ports aren't opened for
you, and [Firewall](../firewall.md) covers the inbound rules.
:::warning[Keep the public relays as a fallback]
Relayed traffic reaches your hostname over the same HTTPS path as everything else, so a
relayed session that goes quiet long enough hits the timeout from
[earlier](#longer-timeout). Leave Tailscale's servers in `derp.urls` alongside your own
rather than removing them.
:::
## Next steps
- [App Configuration](../app-configuration.md) — the full `app.toml` reference in context
- [Persistent Storage](../disks.md) — local vs. Miren disks
- [Traffic Routing](../traffic-routing.md) — routes, and TCP/UDP node ports
- [Running Miren on a Tailnet](../tailscale.md) — the reverse case: a cluster that lives on an
overlay network
---
## Deploy the Hermes agent
This worked example deploys Nous Research's
[Hermes agent](https://hermes-agent.nousresearch.com/docs/user-guide/docker) (Docker
image `nousresearch/hermes-agent:latest`) onto a Miren cluster. Hermes ships only as a
Docker image, so Miren pulls and runs that image directly.
The end state: the Hermes **dashboard** served over HTTPS behind basic auth, the
OpenAI-compatible **API server intentionally disabled**, and all agent state persisted
on a Miren disk at `/opt/data`.
Along the way it exercises a lot of real Miren behavior: direct image deploys,
the `web` service convention, `0.0.0.0` binding, `port_timeout`, and network disks — so
it doubles as a tour of what matters when you bring your own image.
:::info[This is an application recipe, not a language guide]
For getting your own source code onto Miren, start with [Deployment](../deployment.md) and the
[Language Guides](../guides/index.md). This page is about running a prebuilt third-party image.
:::
## Prerequisites
- `miren` CLI installed and authenticated (`miren whoami`).
- Access to the target cluster and its org.
- An Anthropic API key for the agent's LLM calls. (This example uses Anthropic; the
`OPENAI_API_KEY` in `app.toml` is optional — supply it too if your agent uses OpenAI.)
## Select the target cluster
If the cluster isn't configured locally yet, list what your identity can see and add it
(this pins the TLS fingerprint):
```bash
# List clusters available to your cloud identity
miren cluster add -i cloud
# Add it by name + public address (from the list above)
miren cluster add -c hermes -a :8443 -i cloud
miren whoami -C hermes
```
The commands below target it explicitly with `-C hermes`. Omit `-C` to use your default
cluster.
## The app.toml
```toml
name = "hermes"
[services.web]
image = "nousresearch/hermes-agent:latest"
args = ["gateway", "run"]
port = 9119
port_type = "http"
port_timeout = "180s"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
[[services.web.disks]]
name = "data"
provider = "miren"
mount_path = "/opt/data"
size_gb = 10
filesystem = "ext4"
# Dashboard (the single routed, health-checked port), basic auth.
[[env]]
key = "HERMES_DASHBOARD"
value = "1"
[[env]]
key = "HERMES_DASHBOARD_HOST"
value = "0.0.0.0"
[[env]]
key = "HERMES_DASHBOARD_PORT"
value = "9119"
[[env]]
key = "HERMES_DASHBOARD_BASIC_AUTH_USERNAME"
value = "admin" # change to any username you prefer
[[env]]
key = "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD"
required = true
sensitive = true
[[env]]
key = "ANTHROPIC_API_KEY"
required = true
sensitive = true
[[env]]
key = "OPENAI_API_KEY"
sensitive = true
```
The image's `ENTRYPOINT` is `/opt/hermes/docker/entrypoint-dispatch.sh`. When it runs as
PID 1, the dispatcher starts s6-overlay and passes the arguments through to Hermes' main
wrapper. The `args` field supplies `gateway` and `run` as separate arguments without
replacing that startup path. The image doesn't expose a port, so the recipe declares the
dashboard's port explicitly.
:::warning[Name the HTTP service `web`]
Miren's HTTP ingress routes a hostname to the app's `web` service by default, so the
easiest layout is to name the HTTP service `web`. Name it something else and `miren route set`
rejects it unless you pass `--service `. If you want a **portless background worker**
instead (reachable only via a messaging platform or `miren sandbox exec`), name the service
something else and omit `port`/`port_type` — then Miren does no HTTP ingress and no port
health check.
:::
Two details in that config bite if you skip them. Miren health-checks and routes to the
port from *outside* the container, but Hermes components default to `127.0.0.1` — set
`HERMES_DASHBOARD_HOST=0.0.0.0` (and `API_SERVER_HOST=0.0.0.0` if you ever enable the API
server) or the health check reports `nothing is listening on :`. And first boot is
slow: s6 init, first-boot seeding, and a config-schema migration all run before the port
binds, so `port_timeout = "180s"` keeps the check from giving up at the 15-second default.
:::warning[A Miren disk forces single-instance, churny rollouts]
A `provider = "miren"` (network) disk holds one exclusive lease, so it requires
`concurrency mode = "fixed"` with `num_instances = 1`. On redeploy the new instance can't
mount `/opt/data` until the old one releases the lease, so `miren deploy` frequently prints
`did not become healthy` while the instance actually comes up a few seconds later —
re-check `miren app status` / `miren sandbox list` before assuming failure. If you'd rather
have snappy rollouts and don't need node-independent storage, use `provider = "local"` (no
lease, no fixed-instance requirement; data is node-local).
:::
:::danger[Keep the OpenAI API server disabled unless sandboxed]
Hermes warns that a network-accessible OpenAI-compatible API server with the default
`local` terminal backend runs agent work **as the host user with full file and terminal
access**. This config leaves it off. Enable it only deliberately and sandboxed
(`terminal.backend: docker`) with the port firewalled.
:::
## Deploy
Non-secret config lives in `app.toml`. Pass secrets with `-s` (masked in output, stored
server-side); never bake them into the image:
```bash
# Generate the dashboard password once and save it somewhere safe:
DASH_PW=$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)
miren deploy -a hermes -C hermes -f \
-s "ANTHROPIC_API_KEY=sk-ant-..." \
-s "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=$DASH_PW"
```
Validate the config without building at any time:
```miren
miren deploy --analyze -a hermes -C hermes
```
:::warning[`miren env delete` redeploys the current server spec]
To remove a previously-set secret (`manual`) env var, use `miren env delete` — but it
builds the new version by copying the *current server-side* spec, **not** your local
`app.toml`. Order matters: deploy your new `app.toml` first, then delete the stale env
var. Config-source vars (declared in `app.toml`) drop automatically when you remove them
and redeploy.
:::
## Add a hostname route
```miren
miren route set gw.hermes.clusters.miren.run hermes -C hermes
miren route list -C hermes
```
`route set ` routes the host to the app's `web` service by default; pass
`--service ` to target another HTTP service. `*.clusters.miren.run` DNS and TLS
are managed by the cluster; give it a few seconds to provision.
## Verify
```bash
miren app status -a hermes -C hermes # Current Version + active
miren sandbox list -C hermes # one running sandbox, service "web"
miren logs -a hermes -C hermes --since 5m # look for HERMES_DASHBOARD_READY port=9119
# Route should 302 -> /login (200), served by uvicorn:
curl -sL -o /dev/null -w "%{http_code} %{url_effective}\n" https://gw.hermes.clusters.miren.run/
```
Healthy signs in the logs: `s6-rc: service main-hermes successfully started`,
`Fixing ownership of /opt/data`, `config schema 0 -> NN`, and
`HERMES_DASHBOARD_READY port=9119`.
## Connecting to the agent
- **Dashboard:** `https://gw.hermes.clusters.miren.run` (the basic-auth username from
`app.toml` — `admin` unless you changed it — plus the password from the deploy step).
- **CLI (no port needed):** `miren sandbox exec -C hermes`, then run the
`hermes` CLI inside the container.
- **Messaging platform (no inbound port):** set `TELEGRAM_BOT_TOKEN`; the agent dials out
and you DM the bot.
## Roadblock checklist
1. Set `args = ["gateway", "run"]` so Miren preserves the image's dispatcher `ENTRYPOINT` and supplies the gateway subcommand.
2. Name the HTTP service `web` (or pass `--service ` to `miren route set`).
3. Bind services to `0.0.0.0`, not `127.0.0.1`.
4. `port_timeout = "180s"` for the slow s6 first boot.
5. A `miren` disk means `fixed` / `num_instances = 1`; expect `did not become healthy` noise on redeploy (verify state before retrying). Use `local` for snappy rollouts.
6. Deploy the new `app.toml` *before* `miren env delete` (it copies the live spec).
7. Keep the OpenAI API server off unless sandboxed — it runs agent work as the host user.
## Next steps
- [App Configuration](../app-configuration.md) — the full `app.toml` reference in context
- [Persistent Storage](../disks.md) — Miren disks vs. local disks
- [Traffic Routing](../traffic-routing.md) — how the `web` service and routes fit together
- [Services](../services.md) — image defaults, `args`, and full command overrides
---
## Run an OpenHands agent server
[OpenHands](https://docs.openhands.dev) is an AI coding agent. Its
[Agent Canvas](https://docs.openhands.dev/openhands/usage/agent-canvas/backends) architecture
splits into a **backend** (an *agent server* that runs the agent in a workspace) and a
**frontend** (any Agent Canvas client that connects to it). This recipe deploys the
**agent server** as a self-contained Miren app.
The end state: OpenHands' agent server running as one Miren `web` service on port 8000,
executing the agent **inside its own container** — no Docker socket, no sibling sandbox
container — behind an HTTP/WebSocket API protected by a session key, with conversation and
workspace state on a persistent disk. You point an Agent Canvas client (or the OpenHands SDK)
at its URL to drive it.
:::info[This is the backend, not the whole UI]
OpenHands' full local app spawns a **separate sandbox container per conversation via the host
Docker socket** — which Miren does not expose. The *agent server* is the piece that runs the
agent in-process in its own container, so it's the part that fits Miren cleanly. Run an
[Agent Canvas](https://docs.openhands.dev/openhands/usage/agent-canvas) client elsewhere (or in
the browser) and connect it to this server.
:::
## How it works
1. The Miren `web` service runs `openhands-agent-server` on `0.0.0.0:8000`.
2. It authenticates clients with a session key (`X-Session-API-Key` header).
3. A client (Agent Canvas or the SDK) creates a conversation, passing the LLM model + key and
the agent's tools. The agent runs **in this container**, reading/writing its workspace on
the mounted disk.
4. Conversations, the agent workspace, and bash-event history persist under `/workspace`.
## Prerequisites
- `miren` CLI installed and authenticated (`miren whoami`).
- Access to the target cluster and its org.
- An **LLM API key** (e.g. Anthropic) — supplied by the *client* when it starts a
conversation, and encrypted at rest by the server's `OH_SECRET_KEY`.
## Select the target cluster
```bash
# Add a cluster your cloud identity can see (interactive picker; pins the TLS fingerprint)
miren cluster add -i cloud
miren whoami -C openhands
```
The commands below target it with `-C openhands`. Omit `-C` to use your default cluster.
## Use the upstream image
OpenHands publishes a ready-to-run Python agent-server image. Miren can use its working
directory, entrypoint, and server defaults directly, so this recipe doesn't need a Dockerfile
or a shell command. The versioned tag keeps deploys reproducible. Bump it deliberately when
you want a newer OpenHands release.
## The app.toml
This file lives at `.miren/app.toml`.
```toml
name = "openhands-agent-server"
[services.web]
image = "ghcr.io/openhands/agent-server:1.44.1-python"
# The image exposes the API and noVNC ports, so select the API port explicitly.
port = 8000
port_type = "http"
port_timeout = "300s"
[services.web.concurrency]
mode = "fixed"
num_instances = 1
# Node-local persistence for conversations, the agent workspace, and bash-event history.
[[services.web.disks]]
name = "workspace"
provider = "local"
mount_path = "/workspace"
# CORS origin for a browser-based Agent Canvas client (set to your frontend's URL).
[[env]]
key = "OH_ALLOW_CORS_ORIGINS_0"
value = "https://your-agent-canvas-frontend.example.com"
[[env]]
key = "OH_SESSION_API_KEYS_0"
required = true
sensitive = true
[[env]]
key = "OH_SECRET_KEY"
required = true
sensitive = true
```
The server defaults to port 8000. Setting `OH_SESSION_API_KEYS_0` enables authentication and
also makes it listen on `0.0.0.0`, so no command-line override is needed.
:::warning[Use a `local` disk, and let it run as the image's user]
Mount the image's `/workspace` directory with `provider = "local"`. The server's default
conversation, project, and bash-event paths all live below that directory. The image runs as
its own `openhands` user, and Miren makes the disk writable for that user automatically.
:::
:::warning[The agent runs arbitrary shell in this container]
A client can make the agent execute shell commands against `/workspace`. The pinned image
grants the `openhands` user passwordless `sudo`, so those commands can become root inside the
container. Treat the container as the isolation boundary and the workspace as untrusted. Use
a dedicated, revocable session key and keep the server reachable only over its authenticated
API.
:::
Unlike a typical app, the agent server takes no `LLM_API_KEY` of its own — the client passes the
model and key when it creates a conversation, and `OH_SECRET_KEY` encrypts those secrets where
the server persists them.
## Deploy
Generate the two secrets once, save them somewhere safe, and pass them with `-s`:
```bash
SESSION_KEY=$(openssl rand -hex 32) # clients send this as X-Session-API-Key
SECRET_KEY=$(openssl rand -hex 32) # encrypts stored secrets
miren deploy -a openhands-agent-server -C openhands -f \
-s "OH_SESSION_API_KEYS_0=$SESSION_KEY" \
-s "OH_SECRET_KEY=$SECRET_KEY"
```
Validate the config without building at any time:
```bash
miren deploy --analyze -a openhands-agent-server -C openhands
```
## Add a hostname route
```bash
miren route set agent.openhands.clusters.miren.run openhands-agent-server -C openhands
miren route list -C openhands
```
## Verify
```bash
miren app status -a openhands-agent-server -C openhands # Current Version + active
miren sandbox list -C openhands # one running sandbox, service "web"
# Health check is public; the API requires the session key:
curl -s -o /dev/null -w "%{http_code}\n" https://agent.openhands.clusters.miren.run/alive # 200
curl -s -o /dev/null -w "%{http_code}\n" https://agent.openhands.clusters.miren.run/api/conversations # 401
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-Session-API-Key: $SESSION_KEY" \
https://agent.openhands.clusters.miren.run/api/conversations # 200/422 (auth OK)
```
## Connect a client
Run an [Agent Canvas](https://docs.openhands.dev/openhands/usage/agent-canvas) frontend
(locally, or as its own service), open **Manage Backends → Add Backend**, and enter:
- **URL:** `https://agent.openhands.clusters.miren.run`
- **API key:** the **session key** you generated for `OH_SESSION_API_KEYS_0` at deploy time
(the `$SESSION_KEY` from the deploy step). This is the same value clients send as the
`X-Session-API-Key` header.
:::warning[Save the session key when you generate it]
`OH_SESSION_API_KEYS_0` is a `sensitive` env var, so Miren masks it in `miren app status` and
logs — it can't be read back after deploy. Store the value you generated somewhere safe (a
password manager); it's what you paste into the frontend. Lost it? Deploy again with a new
`-s "OH_SESSION_API_KEYS_0=..."` to rotate it, then update the frontend.
:::
For a browser-based frontend, set `OH_ALLOW_CORS_ORIGINS_0` (above) to that frontend's origin.
You can also drive the server directly with the OpenHands SDK, which sends the same
`X-Session-API-Key` header and passes your LLM config per conversation.
## Roadblock checklist
1. The image exposes both **8000** (API) and **8002** (noVNC), so select `port = 8000` explicitly.
2. Mount the disk at `/workspace` so the image's default state paths land on it.
3. Use a `provider = "local"` disk and keep the image's `openhands` user.
4. Clients authenticate with the `X-Session-API-Key` header; set `OH_SESSION_API_KEYS_0` and `OH_SECRET_KEY`.
5. For a browser frontend, set `OH_ALLOW_CORS_ORIGINS_0` to its origin.
6. The client — not the server — supplies the LLM model and key.
7. Raise `port_timeout`; the image is large and first boot is slow.
## Next steps
- [App Configuration](../app-configuration.md) — the full `app.toml` reference in context
- [Persistent Storage](../disks.md) — local vs. Miren disks
- [Traffic Routing](../traffic-routing.md) — how the `web` service and routes fit together
- [Deploy the Amp agent runner](./amp-runner.md) — another headless coding-agent recipe
---
## Protecting Routes
:::tip[Looking for CI/CD OIDC?]
If you want to **deploy from GitHub Actions or other CI systems** using OIDC tokens (no stored secrets), see [CI/CD Deployment with OIDC](./ci-deploy.md). This page covers a different feature: protecting your app's HTTP routes with a login.
:::
Route protection puts a login in front of an application at the routing layer, without you writing any auth code in the app. There are two ways to do it:
- **[OIDC single sign-on](#quick-start)** sends unauthenticated requests off to an identity provider like Google or GitHub, then hands the resulting JWT claims to your app as HTTP headers. Reach for this when you want to know who each user is and gate access per person.
- **[Password protection](#password-protection)** shows visitors a login form and lets them in once they type a shared password. It's a good fit for staging sites and internal tools, where you just want to keep the public out without standing up an identity provider.
Both work the same way: add an auth provider, then attach it to a route with `miren route protect`. OIDC comes first below, then password protection.
With OIDC, your app reads identity straight off plain HTTP headers like `X-User-Email`, so there's no auth code to write. Any standards-compliant identity provider works.
## Minimum working example
The fastest way to put a login in front of a route is a shared password:
```miren
miren auth provider add password staging-pw
miren route protect staging.example.com --provider staging-pw
```
Visitors now get a login form; entering the right password sets a 24-hour session cookie. For per-user identity from a provider like Google or GitHub, use the OIDC [Quick Start](#quick-start) below instead.
## Quick Start
**Step 1: Add an identity provider**
```miren
miren auth provider add oidc my-google \
--provider-url https://accounts.google.com \
--client-id $GOOGLE_CLIENT_ID \
--client-secret $GOOGLE_CLIENT_SECRET \
--scope email --scope profile
```
**Step 2: Protect a route**
```miren
miren route protect myapp.example.com \
--provider my-google \
--claim-header email:X-User-Email \
--claim-header name:X-User-Name
```
That's it. Unauthenticated requests to `myapp.example.com` are now redirected to Google for login. After authentication, your app receives `X-User-Email` and `X-User-Name` headers.
## Authentication vs Authorization
This feature handles **authentication**: it confirms who a user is. It doesn't decide what they're allowed to do, which is **authorization** and stays in your app's hands.
Once a user is authenticated, your app gets their claims as headers and takes it from there. If you set up Google as the provider, for example, your app sees the user's email address and can check the domain before letting them do anything.
## Trust Model
Your app can trust the claim headers like `X-User-Email` because Miren is the only network path into the sandbox. External clients can't reach your app directly. Miren validates the JWT from the identity provider and sets the headers before it proxies the request along.
This "trust the proxy" pattern is the same approach OAuth2 Proxy and nginx's `auth_request` take. It works well when the platform controls the network, which Miren does through sandbox isolation.
So your app doesn't have to verify signatures or validate tokens. It can treat the claim headers as trusted input from the platform.
## How Claim Mappings Work
The `--claim-header` option maps JWT claims from the identity provider to HTTP headers your app receives:
```miren
miren route protect myapp.example.com \
--provider my-google \
--claim-header email:X-User-Email \
--claim-header sub:X-User-ID \
--claim-header name:X-User-Name
```
- Each mapping takes the form `CLAIM:HEADER`.
- Pass `--claim-header` as many times as you need.
- Claims that aren't in the JWT are skipped.
- Your app sees the rest as ordinary request headers.
## Example Provider Setups
### Google
Google's identity provider is a good pick when you want to limit access to a particular email domain.
```miren
miren auth provider add oidc my-google \
--provider-url https://accounts.google.com \
--client-id $GOOGLE_CLIENT_ID \
--client-secret $GOOGLE_CLIENT_SECRET \
--scope email --scope profile
```
Your app can then look at the domain on the `X-User-Email` header for basic access control, for example only allowing `@yourcompany.com`.
### GitHub
GitHub doesn't expose a native OIDC endpoint, so Miren talks to it through a connector instead of the OIDC discovery path. You still get the same end result: identity arrives at your app as plain HTTP headers.
Register an OAuth App in GitHub (Settings → Developer settings → OAuth Apps) with the callback URL `https://your-route.example.com/.well-known/miren/oidc/callback`, then add the provider with `add github` and the orgs you want to allow:
```miren
miren auth provider add github my-github \
--client-id $GITHUB_CLIENT_ID \
--client-secret $GITHUB_CLIENT_SECRET \
--org mirendev
```
To restrict by team membership, suffix the org with one or more team slugs:
```miren
miren auth provider add github my-github \
--client-id $GITHUB_CLIENT_ID \
--client-secret $GITHUB_CLIENT_SECRET \
--org mirendev:engineering,platform
```
Repeat `--org` for multiple organizations. Attach the provider to a route as usual:
```miren
miren route protect myapp.example.com \
--provider my-github \
--claim-header email:X-User-Email \
--claim-header preferred_username:X-User-Login \
--claim-header groups:X-User-Groups
```
The connector exposes the user's GitHub login as `preferred_username`, and any org/team memberships as `groups` (in the form `org:team`). The full set of claims available is `sub`, `email`, `email_verified`, `name`, `preferred_username`, and `groups`. Mix and match `--claim-header` mappings to surface the bits your app cares about.
A wrinkle worth knowing: the `groups` claim only carries entries for *teams* the user belongs to within configured orgs. If you set `--org NAME` with no team suffix, the user is authorized (they have to be in NAME to get in) but `groups` comes back empty because there are no team memberships to surface. For an org membership signal, lean on the fact that the request reached your app at all, or set team filters with `--org NAME:team1,team2` to populate `groups`.
### GitLab
GitLab has a built-in OIDC provider, so there's no federation layer to set up. Register an application in your GitLab instance and point Miren straight at it.
```miren
miren auth provider add oidc my-gitlab \
--provider-url https://gitlab.com \
--client-id $GITLAB_CLIENT_ID \
--client-secret $GITLAB_CLIENT_SECRET \
--scope email --scope profile
```
### Keycloak (self-hosted)
[Keycloak](https://www.keycloak.org/) is an open-source identity provider you run yourself. It gives you a lot of control over how users federate in and how claims are configured.
```miren
miren auth provider add oidc my-keycloak \
--provider-url https://keycloak.example.com/realms/myrealm \
--client-id $KEYCLOAK_CLIENT_ID \
--client-secret $KEYCLOAK_CLIENT_SECRET \
--scope email --scope profile
```
## Password Protection
When you don't need to know who a visitor is and just want to keep an app behind a shared secret, protect the route with a password instead of an OIDC provider. Visitors get a login form and type the password to get in. There are no usernames and no per-user identity; anyone with the password is let through.
**Step 1: Add a password provider**
```miren
miren auth provider add password staging-pw
```
Leave off `--password` and Miren prompts you for it (the input isn't shown), which keeps the password out of your shell history. You can also pass it inline or read it from a file:
```miren
# Provide the password inline
miren auth provider add password staging-pw --password s3cret
# Read the password from a file (handy for scripts and CI)
miren auth provider add password staging-pw --password @./password.txt
```
Miren only stores a bcrypt hash of the password, never the plaintext.
**Step 2: Protect a route**
```miren
miren route protect staging.example.com --provider staging-pw
```
`route protect` figures out on its own whether the named provider is OIDC or password, so the command is the same for both. That's all there is to it. Unauthenticated requests to `staging.example.com` now get a login form, and anyone who enters the right password is let through.
`--claim-header` has nothing to map for a password provider (there are no JWT claims), so it's ignored if you pass it.
### How the Login Flow Works
- An unauthenticated visitor gets a **"Password Required"** form, whatever path they asked for.
- When they enter the right password, Miren drops a session cookie (`miren_pw_session`) and sends them back to the page they were after. The session is good for **24 hours**, then they log in again.
- A wrong password reloads the form with an "Incorrect password." message.
- The cookie is tied to its route, so it can't be reused against a different protected host.
To sign out, send the visitor to `/.well-known/miren/auth/logout`. That clears the session cookie and redirects to `/`.
### Rotating the Password
Provider names have to be unique, so adding one that already exists is rejected unless you pass `--update`. That overwrites the provider and rotates the password:
```miren
miren auth provider add password staging-pw --update
```
Sessions that are already open stay valid until they expire (24 hours). New logins need the new password.
### Default Route
Like OIDC, password protection works on the default route via `--default`:
```miren
miren route protect --default --provider staging-pw
```
## Reusing Providers Across Routes
Once you've added a provider, you can use it to protect any number of routes:
```miren
miren route protect app1.example.com --provider my-google --claim-header email:X-User-Email
miren route protect app2.example.com --provider my-google --claim-header email:X-User-Email
```
## Default Route Support
`route protect` and `route unprotect` support the `--default` flag for the default route, which has no static hostname. On the default route, Miren works out the OAuth2 redirect URL from the request's `Host` header at runtime.
```miren
miren route protect --default \
--provider my-google \
--claim-header email:X-User-Email
```
## Managing Providers
The same commands manage both OIDC and password providers:
```miren
# List all providers
miren auth provider list
# Show details of a provider
miren auth provider show my-google
# Remove a provider
miren auth provider remove my-google
```
Removing a provider that's still attached to a route is refused. Pass `--force` to remove it anyway, which leaves those routes unprotected:
```miren
miren auth provider remove staging-pw --force
```
## Removing Route Protection
```miren
# Remove protection from a route (provider is preserved for reuse)
miren route unprotect myapp.example.com
# Check route status including protection info
miren route show myapp.example.com
```
See the [CLI reference](./command/route-protect.md) for the full list of options.
---
## Application Scaling
Miren automatically scales your application instances based on traffic. This page explains how scaling works and how to configure it for your needs.
:::info[Scaling out across machines]
This page is about how many instances of your app run. When the machine itself runs out of room, you add more machines to the cluster instead: see [Distributed Runners](./distributed-runners.md).
:::
## Minimum working example
Configure scaling in `.miren/app.toml` under `[services..concurrency]`:
```toml
[services.web.concurrency]
mode = "auto"
requests_per_instance = 10
scale_down_delay = "15m"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
```
`auto` scales the service with traffic, all the way down to zero when idle; `fixed` runs an exact instance count. Deploy and the settings take effect. Both modes and every field are explained below.
## How Autoscaling Works
By default, Miren uses **autoscaling** for web services. As traffic to your application increases, Miren automatically launches additional instances to handle the load. When traffic decreases, instances are scaled back down.
This approach is similar to Google Cloud Run's scaling model: instead of guessing how many replicas you need, Miren observes actual demand and adjusts automatically.
### Why Autoscaling by Default?
Guessing instance counts is error-prone:
- Too few instances and your app can't handle traffic spikes
- Too many instances waste resources when traffic is low
- Manual scaling requires constant monitoring and adjustment
With autoscaling, Miren handles this automatically so you can focus on your application.
### Scale to Zero
Miren can scale your application all the way down to zero instances when there's no traffic. This is particularly valuable for self-hosted deployments where resource efficiency matters:
- **Better utilization**: Run dozens of apps on a single server—only active apps consume resources
- **Lower costs**: Development, staging, and low-traffic production apps don't waste memory or CPU
- **No idle tax**: Internal tools, webhooks, and scheduled tasks don't need dedicated instances waiting around
When a request arrives for a scaled-to-zero app, Miren quickly spins up an instance to handle it. The first request may have slightly higher latency, but subsequent requests are served immediately.
## Scaling Modes
Miren supports two scaling modes:
| Mode | Description | Use Case |
|------|-------------|----------|
| `auto` | Scales instances based on traffic | Stateless web services, APIs |
| `fixed` | Runs a set number of instances | Databases, workers, stateful services |
## Default Behavior
When you deploy without explicit configuration:
- **`web` service**: Auto mode with 10 concurrent requests per instance, 15-minute scale-down delay
- **All other services**: Fixed mode with 1 instance
## Configuring Scaling
Configure scaling in your `.miren/app.toml` file under `[services..concurrency]`.
### Auto Mode (Default for Web)
Auto mode scales instances based on concurrent requests:
```toml
[services.web.concurrency]
mode = "auto"
requests_per_instance = 10
scale_down_delay = "15m"
```
#### Auto Mode Options
| Option | Description | Default |
|--------|-------------|---------|
| `mode` | Must be `"auto"` | `"auto"` for web |
| `requests_per_instance` | Target concurrent requests per instance | 10 |
| `scale_down_delay` | How long to wait before scaling down idle instances | 15m |
#### How Auto Mode Calculates Instances
Miren targets `requests_per_instance` **concurrent** requests per instance, the number of in-flight requests being handled simultaneously, not requests over a period of time. For example, with `requests_per_instance = 10`:
- 5 concurrent requests → 1 instance
- 15 concurrent requests → 2 instances
- 100 concurrent requests → 10 instances
This means a single instance handling fast requests (e.g., 10ms each) can serve thousands of requests per second while staying under the concurrency limit.
The `scale_down_delay` prevents thrashing when traffic fluctuates. An instance won't be terminated until it has been idle for this duration.
### Fixed Mode
Fixed mode runs a specific number of instances regardless of traffic:
```toml
[services.worker.concurrency]
mode = "fixed"
num_instances = 3
```
#### Fixed Mode Options
| Option | Description | Default |
|--------|-------------|---------|
| `mode` | Must be `"fixed"` | `"fixed"` for non-web |
| `num_instances` | Exact number of instances to run | 1 |
## Examples
### High-Traffic API
For an API that handles many concurrent requests:
```toml
[services.web.concurrency]
mode = "auto"
requests_per_instance = 50
scale_down_delay = "5m"
```
This configuration:
- Allows 50 concurrent requests per instance (higher density)
- Scales down after 5 minutes of reduced traffic (faster scale-down)
### Background Worker
For a background job processor:
```toml
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
```
This runs exactly 2 worker instances at all times.
### Database Service
For a database that should always be running:
```toml
[services.db]
image = "postgres:16"
[services.db.concurrency]
mode = "fixed"
num_instances = 1
```
### Complete Multi-Service App
```toml
name = "myapp"
# Web service: autoscales based on traffic
[services.web.concurrency]
mode = "auto"
requests_per_instance = 20
scale_down_delay = "10m"
# Worker service: fixed at 3 instances
[services.worker.concurrency]
mode = "fixed"
num_instances = 3
# Database: single instance with persistent storage
[services.db]
image = "postgres:16"
[services.db.concurrency]
mode = "fixed"
num_instances = 1
[[services.db.disks]]
name = "postgres-data"
mount_path = "/var/lib/postgresql/data"
size_gb = 20
```
## Scaling and Disks
Services with persistent disks must use fixed mode with exactly 1 instance:
```toml
[services.db.concurrency]
mode = "fixed"
num_instances = 1
[[services.db.disks]]
name = "db-data"
mount_path = "/data"
size_gb = 10
```
This restriction exists because disks use exclusive leasing where only one instance can mount a disk at a time.
## Tuning Tips
### Choosing `requests_per_instance`
- **Lower values** (5-10): More responsive scaling, higher resource usage
- **Higher values** (50-100): More efficient resource usage, may have slower response to traffic spikes
Start with the default (10) and adjust based on your application's characteristics.
### Choosing `scale_down_delay`
- **Shorter delays** (2m-5m): Faster resource reclamation, but may cause more scaling churn
- **Longer delays** (15m-30m): More stable instance counts, but holds resources longer
Consider your traffic patterns:
- Bursty traffic: Use longer delays to avoid constant scaling
- Predictable traffic: Shorter delays work well
## Monitoring Scaling
View your current instance counts:
```miren
# See app status including instance counts
miren app
# Watch instance counts in real-time
miren app --watch
# List all running sandboxes
miren sandbox list
```
## Next Steps
- [app.toml Reference — Concurrency](./app-toml.md#concurrency) — Complete field reference for concurrency settings
- [Services](./services.md) — Define multiple processes in your app
- [Distributed Runners](./distributed-runners.md) — Add machines when one server isn't enough
- [Getting Started](./getting-started.md) — Deploy your first app
---
## Secrets
For a real credential, `miren env set -s` is not enough: it masks the value in output, but the value itself still sits in your config. A secret is the alternative — a credential Miren holds **encrypted**, that your app config **points at** rather than contains.
| | `miren env set -s KEY` | `miren secret set` |
|---|---|---|
| Stored | plaintext in the cluster store | encrypted at rest |
| In CLI output | masked | never present — only the reference is |
| Rotation | edit the variable on each app | rotate once, apps re-pin |
| History | none | immutable versions you can roll back to |
Use `-s` for values that are merely noisy in a terminal. Use a secret for anything that would matter if it leaked.
Miren decrypts a secret only into memory, never back to the cluster store in the clear. This happens at two points by default: when a deploy resolves your reference to a concrete version, and when the container that needs the value is created. A Dockerfile build can add a third — the value is decrypted into the builder's memory for a single build step (see [Using a secret at build time](#using-a-secret-at-build-time)), which BuildKit keeps out of the image unless your own build command copies or prints it.
## Storing a secret
```miren
miren secret set payments/stripe-key
```
You are prompted with masking; the value is never echoed, logged, or written to disk by the CLI. Each write mints an immutable version and prints its handle:
```text
Stored payments/stripe-key@x1A (cluster, enabled)
```
That `@x1A` is the version. It is an opaque id, not a counter.
To read a value from a file instead of being prompted — useful for certificates and JSON keys — use `--value @filename`. A trailing newline is trimmed, since a credential with one appended is the most common way a copied token stops working.
```miren
miren secret set tls/cert --value @server.pem
```
Storing a value identical to the current version is reported as unchanged rather than minting a duplicate, so re-running the command is safe.
## Using a secret in an app
Point an environment variable at it. The value does not travel through your shell, your config file, or your terminal — only the reference does.
```miren
miren env set -e STRIPE_API_KEY --ref payments/stripe-key
```
`--backend` defaults to `cluster`, so it is only needed when you have another backend registered.
Or declare it in `app.toml`, which is safe to commit:
```toml
[[env]]
key = "STRIPE_API_KEY"
backend = "cluster"
ref = "payments/stripe-key"
```
Your app reads `STRIPE_API_KEY` as an ordinary environment variable. Nothing in the app needs to know it came from a secret.
`miren env list` shows the reference rather than a mask, because there is no local value to hide:
```text
NAME VALUE SOURCE
STRIPE_API_KEY → cluster:payments/stripe-key@x1A manual
DATABASE_POOL 10 manual
```
Note the asymmetry: `backend` is optional on the command line but **required** in `app.toml`, where a `ref` without one is rejected rather than assumed. A reference cannot be combined with a value — set one or the other.
## Using a secret at build time
An `[[env]]` reference reaches your app at runtime, not during the build. A build sometimes needs a credential of its own — a private package-registry token for `npm ci` or `bundle install`, a license key needed to compile, a key to pull a private dependency. For those, declare a build secret.
:::info[Dockerfile builds only]
Build secrets reach Dockerfile builds only. You wire each one into a `RUN --mount=type=secret` step yourself, and Miren's automatic language builds have no such step — so declaring a build secret on an app that builds from an auto-detected stack is a build error, not a silent no-op.
:::
Declare the secrets under `[build]` in `app.toml`. Each entry has an `id` — the name your Dockerfile mounts it by — plus a `ref` naming the secret. `backend` names which store to resolve against and defaults to `cluster`, Miren's own built-in store (see [Backends](#backends) for registering others), so you set it only when the secret lives in a different backend. The `id` may contain letters, digits, and `_.-`:
```toml
[build]
dockerfile = "Dockerfile"
[[build.secrets]]
id = "npm_token"
backend = "cluster"
ref = "registry/npm-token"
```
Then consume it in the Dockerfile with a secret mount. The value appears as a file under `/run/secrets/` for the duration of that one `RUN`, and BuildKit places it nowhere else on its own — what your command does with it from there is the subject of the warning below:
```dockerfile
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
```
The credential is resolved to plaintext only in the builder's memory, handed to BuildKit over the build session, and mounted into the single `RUN` that asks for it. It never becomes a build argument, and BuildKit never writes it to the build log or an image layer on its own — so it is not visible in `docker history` or to anyone who later pulls the image. This is the difference from an inline value passed with `-e` or `--sensitive`, which is masked in CLI output but does end up baked into the image.
:::warning[Read the mount, don't print it]
BuildKit keeps the value out of layers and out of its own logs, but it cannot stop your own commands from leaking it. If you copy it into an `ENV`, write it to a file a later layer keeps, or print it to stdout or stderr — an `echo`, a `set -x`, a verbose tool — the value ends up in the image or the build log. Read it inside the `--mount=type=secret` `RUN`, use it there, and don't print it.
:::
A build secret must resolve at build time: if the `ref` cannot be found, or the cluster has no secret backend registered, the build fails rather than proceeding without the credential. Each `id` must be unique within the list and contain only letters, digits, and `_.-`, and the value is capped at 500 KB.
## Pinning: which version a deploy used
Every deploy records the exact secret version it resolved. That pin — the `@x1A` above — is stored in the immutable config for that app version.
This is what makes two things work:
- **"What did we ship?"** is answerable for the most sensitive input a deploy has.
- **Rollback restores the right secret.** Rolling back to an older version brings back the value *that version ran with*, not today's. Miren never deletes a version an old config still points at.
## Rotating
Rotating mints a new version. **Running apps are not disturbed** — there is no surprise mid-run swap.
```miren
miren secret set payments/stripe-key
```
How the rotation reaches your apps depends on how the reference was authored:
- **Declared in `app.toml`** — the reference floats, so the next `miren deploy` picks up the new version automatically.
- **Set with `miren env set`** — the reference is pinned when you set it, so re-run the same `env set` command to move it. This rolls out immediately.
To hold a variable at a specific version regardless, write the version into the reference: `--ref payments/stripe-key@x1A`. This is how you run a rotation overlap, with one variable tracking the new value and another holding the outgoing one while the change propagates.
## Revoking
Disabling a version stops it resolving. Anything still pointing at it **fails closed** on its next deploy rather than falling back to a different value — a revoked credential must never silently become a working one.
```miren
miren secret disable payments/stripe-key@x1A
```
Disabling is reversible with `miren secret enable`; the value itself is untouched.
:::warning[Revoking does not reach running processes]
Already-running sandboxes keep the value they started with. Disabling a version prevents new resolutions — it does not reach into processes that already hold the secret. Restart or redeploy the affected apps if you need them off the old value.
:::
:::danger[Destroy cannot be undone]
`miren secret destroy` deletes the value for good, and anything still referencing that version can never resolve again. Prefer `miren secret disable` when responding to a leak: it fails closed just as hard and leaves you able to recover if something still needed the value.
:::
## Seeing what you have
```miren
miren secret list
miren secret versions payments/stripe-key
```
```text
PATH BACKEND CURRENT VERSIONS
payments/stripe-key cluster @m4Q @m4Q @x1A(disabled)
registry/npm-token cluster @p7R @p7R
```
Both support `--format json`.
## How it is stored
Each version is sealed with its own random data key, and only that key is encrypted by a cluster key. So:
- Rotating the cluster key re-encrypts a few bytes per version, never the values themselves.
- A leaked data key exposes exactly one version rather than every secret.
- The cluster key only ever performs small fixed-size operations, so it can move behind a KMS later without your stored data changing shape.
Within Miren, the plaintext exists only transiently and only in memory: while a deploy resolves it, and while the container that needs it is being created. It is never written to the cluster store, a sandbox spec, an image layer, or a log.
On a multi-node cluster the value does cross the network once. A runner holds no key material, so the coordinator decrypts and returns the value over the cluster's authenticated connection, where it lives only long enough to be handed to the container.
Once the container starts, your application holds its own copy for as long as it runs — an environment variable is readable by the process and anything that can inspect it. Miren stops handing the value around; it cannot un-give it to the app you handed it to.
:::warning[Back up the keyring]
The cluster key lives at `/server/secrets.keyring`, alongside the cluster's CA key. **If that file is lost, every stored secret is permanently unrecoverable.** Include it in whatever backs up your data path.
:::
## The cluster key
Everything above rotates *secrets*. The key that encrypts them rotates too, and Miren handles it.
Each stored version is sealed with its own data key, and only that data key is encrypted by the cluster key. Rotating the cluster key therefore rewrites a few dozen bytes per version and never touches the values themselves, so the work does not grow with how large your secrets are.
A cluster rotates its key automatically once it passes 90 days old (`secrets.key_rotation_period`; set it to `0` to rotate only on request). To rotate now, which is what an incident calls for:
```miren
miren secret rotate-key
miren secret keyring
```
```text
KEY CURRENT AGE VERSIONS
Qa1gSJKRrOU - 3 months 12
p9XmK2vNbQs ✓ just now 4
Rotating off Qa1gSJKRrOU — 4 versions rewrapped so far.
```
A key other than the current one with versions still on it is a rotation part-way through. Nothing is unreadable while that runs — every key in the ring can still decrypt what it wrapped — and the old key is retired only once its count reaches zero. An interrupted rotation resumes on its own, because the work left is "versions still on the old key", which the cluster can always re-ask.
:::note[Rotating the key does not touch your secrets]
This changes how values are encrypted at rest. It does not change any secret's value or mint new versions, so nothing referencing a secret needs redeploying.
:::
## Backends
`cluster` is Miren's own store, and is always available. It is one *backend instance* among what a cluster may register — the model is designed so that external managers (Vault, AWS Secrets Manager, GCP Secret Manager) can be registered alongside it under their own names, with references written the same way:
```toml
[[env]]
key = "SIGNING_KEY"
backend = "prod-vault"
ref = "secret/data/storefront#signing"
```
Miren only ever *reads* an external manager — it never writes into one, so that manager stays the source of truth for its own secrets. Only the `cluster` backend ships today.
---
## Server Configuration Reference
Complete reference for Miren server configuration. Settings can be specified via config file, environment variables, or CLI flags.
## Configuration Precedence
Settings are resolved in this order (highest priority first):
1. **CLI flags** — e.g. `--address :9443`
2. **Environment variables** — e.g. `MIREN_SERVER_ADDRESS=:9443`
3. **Config file** — `server.toml`
4. **Defaults**
## Config File
The server reads its config from the first file found:
1. Path specified via `--config`
2. `/etc/miren/server.toml`
3. `{data_path}/config/server.toml` (default: `/var/lib/miren/config/server.toml`)
### Example
```toml
mode = "standalone"
[server]
address = ":8443"
data_path = "/var/lib/miren"
http_request_timeout = 60
[ingress]
mode = "tls-autoprovision"
[tls]
acme_email = "admin@example.com"
[etcd]
start_embedded = true
[buildkit]
gc_keep_storage = "20GB"
gc_keep_duration = "14d"
```
## Server Modes
Miren has two operating modes:
| Mode | Description |
|------|-------------|
| `standalone` | All components (etcd, containerd, buildkit, logs, metrics) run embedded within a single process. **This is the default.** |
| `distributed` | Components run as separate services. Experimental. |
In standalone mode, embedded services start automatically unless explicitly disabled.
## Top-Level Fields
| Field | Type | Default | Env Var | CLI Flag |
|-------|------|---------|---------|----------|
| `mode` | string | `standalone` | `MIREN_MODE` | `--mode`, `-m` |
| `labs` | string[] | `[]` | `MIREN_LABS` | `--labs` |
## `[server]` — Core Settings {#server}
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `address` | string | `:8443` | Address to listen on (`host:port`) | `MIREN_SERVER_ADDRESS` | `--address`, `-a` |
| `runner_address` | string | `localhost:8444` | Runner address (`host:port`) | `MIREN_SERVER_RUNNER_ADDRESS` | `--runner-address` |
| `data_path` | string | `/var/lib/miren` | Root data directory | `MIREN_SERVER_DATA_PATH` | `--data-path`, `-d` |
| `runner_id` | string | `miren` | Runner identifier | `MIREN_SERVER_RUNNER_ID` | `--runner-id`, `-r` |
| `release_path` | string | — | Path to release directory containing binaries | `MIREN_SERVER_RELEASE_PATH` | `--release-path` |
| `config_cluster_name` | string | `local` | Name for this cluster in client config and as the telemetry label fallback when it is not registered with Miren Cloud | `MIREN_SERVER_CONFIG_CLUSTER_NAME` | `--config-cluster-name`, `-C` |
| `skip_client_config` | bool | `false` | Skip writing client config to `clientconfig.d` | `MIREN_SERVER_SKIP_CLIENT_CONFIG` | `--skip-client-config` |
| `http_request_timeout` | int | `60` | HTTP request timeout in seconds (minimum: 1). Cluster-wide default; override per route with `miren route timeout` — see [Request Timeouts](./traffic-routing.md#request-timeouts) | `MIREN_SERVER_HTTP_REQUEST_TIMEOUT` | `--http-request-timeout` |
| `stop_sandboxes_on_shutdown` | bool | `false` | Stop all sandboxes when server shuts down (useful in development) | `MIREN_SERVER_STOP_SANDBOXES_ON_SHUTDOWN` | `--stop-sandboxes-on-shutdown` |
## `[ingress]` — Ingress Settings {#ingress}
Selects the deployment shape for Miren's HTTP/HTTPS ingress. The mode determines where Miren listens and whether it terminates TLS. See [TLS](./tls.md) for cert sourcing under each mode.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `mode` | string | `tls-autoprovision` | Ingress mode: `tls-autoprovision`, `behind-proxy-http`, or `behind-proxy-https` | `MIREN_INGRESS_MODE` | `--ingress-mode` |
| `address` | string | — | Optional bind override (full `host:port`). Replaces the mode's default bind entirely. Ignored under `tls-autoprovision`. | `MIREN_INGRESS_ADDRESS` | `--ingress-address` |
| `trusted_proxy_hops` | int | `1` | Number of trusted proxies immediately in front of Miren. Used to select the visitor address from `X-Forwarded-For` under `behind-proxy-http`. | `MIREN_INGRESS_TRUSTED_PROXY_HOPS` | `--ingress-trusted-proxy-hops` |
### Modes
| Mode | Default bind | TLS terminated | Cert source |
|------|--------------|----------------|-------------|
| `tls-autoprovision` (default) | `0.0.0.0:443` plus `:80` for redirect / HTTP-01 ACME | yes | `[tls]` (ACME or self-signed) |
| `behind-proxy-http` | `127.0.0.1:80` | no | n/a |
| `behind-proxy-https` | `127.0.0.1:443` | yes | `[tls]` (self-signed or DNS-01 ACME) |
Only `behind-proxy-http` trusts `X-Forwarded-Proto` / `Forwarded` from the peer; the proxy must set it. It also uses `X-Forwarded-For` for access logs, selecting the address immediately before the configured number of trusted proxy hops from the right. The other modes derive the scheme and visitor address from the connection itself.
The `behind-proxy-*` modes default to localhost to keep accidental misconfigurations from quietly exposing an internal endpoint to the network. Set `ingress.address = "0.0.0.0:80"` (or similar) explicitly when the proxy is on a different host.
:::warning[Widening `behind-proxy-http` off loopback]
Under `behind-proxy-http`, Miren trusts `X-Forwarded-Proto` from *every* connection to the listener; it does not check the peer address. If you bind to `0.0.0.0`, `[::]`, or any non-loopback address, a firewall or security group must restrict that port to the proxy's addresses. Any other client that can reach it directly can send `X-Forwarded-Proto: http` and be issued auth cookies without the `Secure` flag.
:::
:::info[Unix socket addresses]
`unix:/path` is reserved for a future release and rejected today with a clear error.
:::
## `[tls]` — TLS Settings {#tls}
Settings under `[tls]` cover two kinds of certs. `acme_email`, `acme_dns_provider`, and `self_signed` configure the ingress cert and only apply when Miren terminates TLS (`tls-autoprovision` or `behind-proxy-https`); they're rejected at startup under `behind-proxy-http`. `additional_names` and `additional_ips` are different: they extend the SANs on the API server and etcd certs, which exist regardless of ingress mode, so they're valid under any mode. Under `tls-autoprovision`, `additional_names` entries can also get ingress certs. See [TLS](./tls.md#tls-settings-reference) for how, and for setup guides.
`additional_ips` does more than its name suggests. Alongside adding SANs, every address listed there is passed straight through to the addresses the server advertises to Miren Cloud, skipping the filtering that discovered addresses go through. That makes it the way to pin an address discovery gets wrong — a host behind a static NAT, or one where you want a specific interface used. See [Running Miren on a Tailnet](./tailscale.md) for a worked example, and run `miren debug advertise` on the host to see what discovery decided and why.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `additional_names` | string[] | `[]` | Extra DNS names for the server certificate, and for the ingress certificate under autoprovisioning | `MIREN_TLS_ADDITIONAL_NAMES` | `--dns-names` |
| `additional_ips` | string[] | `[]` | Extra IPs for the server certificate, and forced into the advertised address list | `MIREN_TLS_ADDITIONAL_IPS` | `--ips` |
| `acme_dns_provider` | string | — | DNS provider for ACME DNS-01 challenges (e.g. `cloudflare`, `route53`). Required under `behind-proxy-https` if not using `self_signed`. | `MIREN_TLS_ACME_DNS_PROVIDER` | `--acme-dns-provider` |
| `acme_email` | string | — | Email for ACME account registration | `MIREN_TLS_ACME_EMAIL` | `--acme-email` |
| `self_signed` | bool | `false` | Use self-signed certificates (development only, or behind a TLS-terminating proxy that doesn't verify) | `MIREN_TLS_SELF_SIGNED` | `--self-signed-tls` |
## `[etcd]` — Etcd Settings {#etcd}
Miren uses etcd as its entity store. In standalone mode, an embedded etcd server starts automatically.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `endpoints` | string[] | `[]` | Etcd endpoints (auto-configured when embedded) | `MIREN_ETCD_ENDPOINTS` | `--etcd`, `-e` |
| `prefix` | string | `/miren` | Key prefix in etcd | `MIREN_ETCD_PREFIX` | `--etcd-prefix`, `-p` |
| `start_embedded` | bool | `true`\* | Start embedded etcd server | `MIREN_ETCD_START_EMBEDDED` | `--start-etcd` |
| `client_port` | int | `12379` | Embedded etcd client port | `MIREN_ETCD_CLIENT_PORT` | `--etcd-client-port` |
| `peer_port` | int | `12380` | Embedded etcd peer port | `MIREN_ETCD_PEER_PORT` | `--etcd-peer-port` |
| `http_client_port` | int | `12381` | Embedded etcd HTTP client port (bound to loopback) | `MIREN_ETCD_HTTP_CLIENT_PORT` | `--etcd-http-client-port` |
\* Defaults to `true` in standalone mode only.
:::info[Embedded etcd always comes up with mTLS]
`client_port` binds to all interfaces since embedded etcd always starts with
mTLS. `peer_port` and `http_client_port` bind to `127.0.0.1`. etcd's JSON
gateway is disabled outright; its health and metrics endpoints are
unaffected.
:::
## `[containerd]` — Containerd Settings {#containerd}
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `start_embedded` | bool | `true`\* | Start embedded containerd daemon | `MIREN_CONTAINERD_START_EMBEDDED` | `--start-containerd` |
| `binary_path` | string | `containerd` | Path to containerd binary | `MIREN_CONTAINERD_BINARY_PATH` | `--containerd-binary` |
| `socket_path` | string | — | Path to containerd socket | `MIREN_CONTAINERD_SOCKET_PATH` | `--containerd-socket` |
\* Defaults to `true` in standalone mode only.
## `[buildkit]` — BuildKit Settings {#buildkit}
Controls the BuildKit daemon used for building container images.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `start_embedded` | bool | `true`\* | Start embedded BuildKit daemon | `MIREN_BUILDKIT_START_EMBEDDED` | `--start-buildkit` |
| `socket_path` | string | — | Path to external BuildKit socket (distributed mode) | `MIREN_BUILDKIT_SOCKET_PATH` | `--buildkit-socket` |
| `socket_dir` | string | — | Directory for embedded BuildKit socket | `MIREN_BUILDKIT_SOCKET_DIR` | `--buildkit-socket-dir` |
| `gc_keep_storage` | string | `10GB` | Maximum BuildKit layer cache size | `MIREN_BUILDKIT_GC_KEEP_STORAGE` | `--buildkit-gc-storage` |
| `gc_keep_duration` | string | `7d` | How long to keep cache entries | `MIREN_BUILDKIT_GC_KEEP_DURATION` | `--buildkit-gc-duration` |
\* Defaults to `true` in standalone mode only.
## `[victorialogs]` — Log Storage Settings {#victorialogs}
Controls the embedded VictoriaLogs instance used for application log storage.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `start_embedded` | bool | `true`\* | Start embedded VictoriaLogs server | `MIREN_VICTORIALOGS_START_EMBEDDED` | `--start-victorialogs` |
| `http_port` | int | `9428` | HTTP port in embedded mode | `MIREN_VICTORIALOGS_HTTP_PORT` | `--victorialogs-http-port` |
| `retention_period` | string | `30d` | Retention period (e.g. `30d`, `2w`, `1y`) | `MIREN_VICTORIALOGS_RETENTION_PERIOD` | `--victorialogs-retention` |
| `address` | string | `victorialogs:9428` | Address when not using embedded | `MIREN_VICTORIALOGS_ADDRESS` | `--victorialogs-addr` |
\* Defaults to `true` in standalone mode only.
## `[victoriametrics]` — Metrics Storage Settings {#victoriametrics}
Controls the embedded VictoriaMetrics instance used for Miren's own runtime
metrics. Managed application metrics are sent directly to their remote-write
destination and are not copied into this instance.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `start_embedded` | bool | `true`\* | Start embedded VictoriaMetrics server | `MIREN_VICTORIAMETRICS_START_EMBEDDED` | `--start-victoriametrics` |
| `http_port` | int | `8428` | HTTP port in embedded mode | `MIREN_VICTORIAMETRICS_HTTP_PORT` | `--victoriametrics-http-port` |
| `retention_period` | string | `1` | Retention period in months | `MIREN_VICTORIAMETRICS_RETENTION_PERIOD` | `--victoriametrics-retention` |
| `address` | string | `victoriametrics:8428` | Address when not using embedded | `MIREN_VICTORIAMETRICS_ADDRESS` | `--victoriametrics-addr` |
\* Defaults to `true` in standalone mode only.
## `[metrics.remote_write]` — Managed Application Metrics {#managed-app-metrics}
Configures the Prometheus Remote Write destination for services that enable
managed metrics in `app.toml`. When this section is absent, Miren does not start
vmagent and does not scrape application endpoints.
```toml
[metrics.remote_write]
url = "https://metrics.example.com/api/v1/write"
workload_identity_audience = "metrics.example.com"
```
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `url` | string | — | Absolute HTTP or HTTPS remote-write endpoint | `MIREN_METRICS_REMOTE_WRITE_URL` | `--metrics-remote-write-url` |
| `workload_identity_audience` | string | — | Audience for the short-lived `system:telemetrywriter` bearer token | `MIREN_METRICS_REMOTE_WRITE_AUDIENCE` | `--metrics-remote-write-audience` |
:::warning[Remote-write requirements]
Both fields must be set together. URLs containing credentials are rejected;
authentication always uses the cluster's workload identity.
:::
The coordinator runs one supervised vmagent and keeps its on-disk retry queue
under `/app-metrics`.
:::info[Cluster label selection]
Registered clusters use their Miren Cloud cluster ID for the `miren_cluster`
label. Other clusters use [`server.config_cluster_name`](#server), so set it to
a stable name when several clusters write to the same destination.
:::
## `[app_version]` — Version Retention {#app-version}
Every deploy creates a new version of an app, and Miren keeps a bounded history of them rather than retaining every version forever. Pruning old versions frees the disk space their container images take up and keeps the server's per-app state from growing with every deploy.
A version is retained if it is among the most recent `retention_count` **or** newer than `retention_period` — whichever rule keeps it. The two settings are a floor, not a budget: raising either one keeps more versions. The currently active version is always retained regardless of these limits, and ephemeral (preview) versions are managed separately by their own TTL.
On a frequently-deployed cluster this window can pin more image data than the disk can hold, so Miren tightens retention automatically under pressure: once storage reaches 80%, a sweep drops the `retention_period` floor and keeps the active version plus `retention_count` (plus anything still in use). That makes `retention_count` a hard floor, always honored, while `retention_period` is best-effort and yields when the disk is tight. See [Managing Disk Space](./managing-disk-space.md) for how the reclaim works end to end and what to tune.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `retention_count` | int | `10` | Most-recent versions to keep per app, regardless of age | `MIREN_APP_VERSION_RETENTION_COUNT` | `--app-version-retention-count` |
| `retention_period` | string | `30d` | Keep versions newer than this, regardless of count (e.g. `30d`, `2w`) | `MIREN_APP_VERSION_RETENTION_PERIOD` | `--app-version-retention-period` |
## `[deployment]` — Deployment History Retention {#deployment}
Every deploy, rollback, and config change writes a deployment record, and `miren app history` reads them back. Miren keeps a bounded window of these per app rather than every record forever. The records are small, but a cluster that runs for years accumulates thousands, and every history lookup and reconciliation pass pays to scan them.
A record is retained if it is among the most recent `retention_count` for its app **or** newer than `retention_period` — whichever rule keeps it. A few records are always kept regardless of these limits: the deployment that made the app's current version active, any deployment still in progress or holding the app's deploy lock, and any older record whose status still reads `active`. Pruning a record does not touch the app version it produced; versions have their own [retention](#app-version).
Clusters registered with Miren Cloud keep their full history there. Cloud stores deployments as an archive, so a record pruned here stays visible in cloud, and the runtime only prunes a record once cloud has confirmed it holds it. If the cluster cannot reach cloud, eligible records simply wait; nothing is lost while the link is down. On a cluster that runs without cloud, this window *is* the history, so size it to how far back you want `miren app history` to reach.
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `retention_count` | int | `25` | Most-recent deployment records to keep per app, regardless of age | `MIREN_DEPLOYMENT_RETENTION_COUNT` | `--deployment-retention-count` |
| `retention_period` | string | `30d` | Keep records newer than this, regardless of count (e.g. `30d`, `2w`). `0` keeps every record indefinitely | `MIREN_DEPLOYMENT_RETENTION_PERIOD` | `--deployment-retention-period` |
## `[saga]` — Saga Execution Retention {#saga}
Miren records a saga execution for multi-step operations like creating a sandbox or running a build, so that a server crash mid-operation can resume or roll back cleanly instead of leaving things half-done. Each record holds the outputs of every step it ran.
Once an execution finishes, that record is only useful for looking back at what happened, so Miren deletes it after `retention_period`. Successes and failures are treated the same way. An execution that is still in progress is never deleted at any age, including one stuck retrying a rollback, since those are exactly what the server needs to recover.
That leaves one gap, which Miren closes on its own. An execution can end up with nothing driving it and no way for anything to find it again, and because it never reaches a finished state, retention never considers it either. It would sit in the store forever, and every recovery pass would pay to read it. So an execution that has gone a week without changing state is marked failed, which is both the honest description of it and what lets the ordinary rules take over: whoever owns the operation can retry it, and retention collects the record a `retention_period` later. There's no separate setting for this — a week is far longer than the gap between two steps of a saga that's actually running, and anything the server is working on, or retrying on a loop, stays well clear of it.
:::warning[Indefinite retention grows without bound]
Setting `retention_period` to `0` freezes saga records: nothing is deleted, and nothing in progress is marked failed either. That's useful while investigating an incident, but a cluster where one app repeatedly fails to start can write thousands of executions a day, so it's worth putting back afterward.
:::
| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `retention_period` | string | `7d` | Delete finished saga executions older than this (e.g. `7d`, `24h`). `0` freezes saga records entirely | `MIREN_SAGA_RETENTION_PERIOD` | `--saga-retention-period` |
## Workload Identity Anchor {#workload-identity}
The anchor is the `iss` claim in the tokens this cluster mints for its apps, and the address an outside verifier fetches its public keys from. The signing key is generated on the cluster and never leaves it either way — the anchor decides who *serves* the keys, not who holds them.
There is no configuration field for it, because it is a property of the registration rather than of the server. A cluster registered with Miren Cloud is anchored there, and Miren Cloud serves its discovery; a cluster installed with `--without-cloud` anchors at its own hostname and serves its own.
To change it on a registered cluster, use [`miren server identity-anchor`](./command/server-identity-anchor.md), which handles the restart and the verification overlap that keeps in-flight tokens working. See [Moving the anchor](./workload-identity.md#moving-the-anchor).
---
## Services
A Miren app can run multiple **services**—separate processes that work together as part of the same application. Each service can have its own command, image, environment variables, and scaling configuration.
## Minimum working example
Define one service per process in `.miren/app.toml`:
```toml title=".miren/app.toml"
name = "myapp"
[services.web]
command = "npm start"
[services.worker]
command = "npm run worker"
```
Deploy, and both processes run from your app version's primary image. `web` receives HTTP traffic, `worker` runs alongside it, and each scales independently.
## What is a Service?
A service is a named process within your app. Common patterns include:
- **web**: Your main HTTP server (receives external traffic)
- **worker**: Background job processor
- **postgres**: A database running alongside your app
Services share the same deployment lifecycle—when you deploy your app, all services are updated together. But each service scales independently and can run different code.
## Defining Services
Services can be defined in two ways:
1. **Procfile** — Simple format for defining commands per service
2. **`.miren/app.toml`** — Full configuration with scaling, env vars, and images
### Service Detection
Miren detects services in this order:
1. **`.miren/app.toml`** — Services defined in the `[services.*]` sections
2. **`Procfile`** — Services inferred from Procfile entries
3. **Detected start command** — For an auto-detected language stack (Python, Node, Bun, Go, Ruby, Rust), Miren synthesizes a `web` service from the start command it detects for your framework
If none of these provide a service definition, Miren usually synthesizes a `web` service
for a runnable container image. That service uses the image's `ENTRYPOINT` and `CMD`
unchanged. Set `web = false` to suppress it. An app with tasks but no services must set
`web` explicitly, so Miren does not guess whether it needs a long-running process.
:::note[Custom images keep their startup defaults]
An image built from `Dockerfile.miren` (or a `[build] dockerfile`) can run without an
explicit service. Unless `web = false`, Miren creates a `web` service and uses the image's
`ENTRYPOINT` and `CMD`. Add `[services.web]` when you need to customize how it runs. An app
with tasks but no services must set `web` to `true` or `false` explicitly.
:::
### Using a Procfile
If your app has a `Procfile`, Miren automatically infers services from it:
```text
web: npm start
worker: npm run worker
```
Each line defines a service: the name before the colon, and the command after. This is compatible with Heroku's Procfile format.
For more control (scaling, environment variables, different images), use `.miren/app.toml`:
```toml
[services.web]
command = "npm start"
[services.worker]
command = "npm run worker"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
```
### Same Image, Different Commands
The most common pattern is running multiple processes from the same codebase. Define a command for each service:
```toml
name = "myapp"
[services.web]
command = "npm start"
[services.worker]
command = "npm run worker"
```
Both services use your app version's primary image. The `web` service runs your HTTP server, while `worker` runs a background processor.
#### Example: Rails with Sidekiq
```toml
name = "railsapp"
[services.web]
command = "bundle exec puma -C config/puma.rb"
[services.worker]
command = "bundle exec sidekiq"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
```
#### Example: Python with Celery
```toml
name = "djangoapp"
[services.web]
command = "gunicorn myapp.wsgi:application --bind 0.0.0.0:8000"
port = 8000
[services.worker]
command = "celery -A myapp worker --loglevel=info"
[services.beat]
command = "celery -A myapp beat --loglevel=info"
```
### Existing and Different Images
If your web app already exists as a container image, you can deploy it without source detection or a Dockerfile:
```toml
name = "myapp"
[services.web]
image = "ghcr.io/example/myapp:latest"
```
With no other service fields, Miren keeps the image's working directory, `ENTRYPOINT`,
and `CMD`. A single exposed TCP port becomes the web port automatically. Set `port`
when the image exposes no ports or several, and use `args` or `command` only when its
startup defaults need to change.
:::note[Image selection]
Without a Dockerfile, `services.web.image` selects the app's primary image and Miren launches it directly. This explicit image wins over automatic source detection. A `[build].dockerfile` setting or a discovered `Dockerfile.miren` still wins over both.
An image on another service does not override a buildable source stack. This keeps database and cache sidecars from changing how the web app is built. Only when source detection finds no buildable stack can a lone service image become the fallback; with several service images, set `services.web.image` to choose the primary one.
:::
:::tip[Use addons for databases]
If you just need a PostgreSQL database, consider using an [addon](./addons.md) instead of running it as a service. Addons are fully managed — Miren provisions the database, injects credentials, and handles cleanup. Use a service when you need full control over the database configuration.
:::
For services that need entirely different software—like a database—specify an `image`:
```toml
name = "myapp"
[services.web]
command = "npm start"
[services.postgres]
image = "postgres:16"
[[services.postgres.env]]
key = "PGDATA"
value = "/miren/data/local/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
```
When you specify an `image` on a sidecar service, Miren pulls that container image instead of using your app version's primary image. This lets you run standard database images alongside your application code.
#### Example: Full Stack with PostgreSQL and Redis
```toml
name = "fullstack"
# Your application code
[services.web]
command = "node server.js"
[services.worker]
command = "node worker.js"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
# PostgreSQL database
[services.postgres]
image = "postgres:16"
[[services.postgres.env]]
key = "POSTGRES_PASSWORD"
value = "secret"
[[services.postgres.env]]
key = "PGDATA"
value = "/miren/data/local/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
# Redis cache (data stored in memory, no persistence needed for caching)
[services.redis]
image = "redis:7-alpine"
[services.redis.concurrency]
mode = "fixed"
num_instances = 1
```
## Service Configuration Reference
Each service can configure:
| Option | Description | Default |
|--------|-------------|---------|
| `command` | Shell command that replaces the image startup command (`/bin/sh -c`) | (none) |
| `args` | Exec-form arguments that replace image `CMD` while preserving `ENTRYPOINT`; mutually exclusive with `command` | (none) |
| `image` | Container image to use | App version's primary image |
| `port` | Port the service listens on (single-port shorthand) | For `web` inheriting the primary image: its single exposed TCP port, otherwise 3000 |
| `ports` | Port configuration array (multi-port, see [Traffic Routing](./traffic-routing.md)) | (none) |
| `port_timeout` | Time to wait for the service to bind its port at startup (e.g. `"60s"`, `"2m"`) | `15s` |
| `env` | Service-specific environment variables | (none) |
| `concurrency` | Scaling configuration | See [Scaling](./scaling.md) |
| `concurrency.shutdown_timeout` | Time to wait for graceful shutdown during redeploy | `10s` |
| `disks` | Persistent disk attachments (experimental, see [Disks](./disks.md)) | (none) |
With neither `command` nor `args`, the image's `ENTRYPOINT` and `CMD` run
unchanged. `args` preserves each array element exactly, without shell expansion.
### Environment Variables
Services inherit global environment variables from your app, and can add their own:
```toml
name = "myapp"
# Global env vars - available to all services
[[env]]
key = "LOG_LEVEL"
value = "info"
# Service-specific env vars
[services.web]
command = "npm start"
[[services.web.env]]
key = "NODE_ENV"
value = "production"
[services.worker]
command = "npm run worker"
[[services.worker.env]]
key = "WORKER_CONCURRENCY"
value = "5"
```
## Service Communication
Services within the same app can communicate using internal DNS. Each service is discoverable at `.app.miren`:
```toml
name = "myapp"
[[env]]
key = "DATABASE_URL"
value = "postgres://user:pass@postgres.app.miren:5432/mydb"
[[env]]
key = "REDIS_URL"
value = "redis://redis.app.miren:6379"
[services.web]
command = "npm start"
[services.postgres]
image = "postgres:16"
[[services.postgres.env]]
key = "POSTGRES_PASSWORD"
value = "pass"
[[services.postgres.env]]
key = "PGDATA"
value = "/miren/data/local/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
[services.redis]
image = "redis:7-alpine"
[services.redis.concurrency]
mode = "fixed"
num_instances = 1
```
Connect to other services using their DNS name and standard port—`postgres.app.miren:5432` for PostgreSQL, `redis.app.miren:6379` for Redis. The container images listen on their standard ports by default; Miren doesn't manage these ports.
## Traffic Routing
The `web` service is the default target for external HTTP traffic through Miren's HTTP ingress. Create a route to make your app reachable:
```miren
miren route add myapp.example.com --app myapp
```
When `web` does not inherit a single exposed TCP port from the primary image, it
defaults to port 3000. Override it if your app listens elsewhere:
```toml
[services.web]
command = "gunicorn app:app --bind 0.0.0.0:8000"
port = 8000
```
For non-HTTP services (TCP/UDP), you can expose ports directly using the `ports` array and `node_port`. See [Traffic Routing](./traffic-routing.md) for the full picture — HTTP ingress, L4 routing, multi-port services, and the `PORT` environment variable.
## Service Scaling
Each service scales independently. By default:
- **`web` service**: Autoscales based on traffic (scale-to-zero enabled)
- **All other services**: Fixed at 1 instance
Configure scaling per-service:
```toml
[services.web.concurrency]
mode = "auto"
requests_per_instance = 20
scale_down_delay = "10m"
[services.worker.concurrency]
mode = "fixed"
num_instances = 3
```
For detailed scaling configuration, see [Application Scaling](./scaling.md).
## Persistent Storage
For stateful services like databases, use [Local Storage](./disks.md#local-storage)—persistent storage automatically available at `/miren/data/local`. Configure your database to store data there:
```toml
[services.postgres]
image = "postgres:16"
[[services.postgres.env]]
key = "PGDATA"
value = "/miren/data/local/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
```
The `PGDATA` environment variable tells PostgreSQL where to store its data.
:::warning[PostgreSQL data directory]
Using a subdirectory (`pgdata`) under `/miren/data/local` is required because PostgreSQL expects to own its data directory.
:::
For cloud-synced storage that travels with your app, see [Miren Disks](./disks.md#miren-disks) (experimental).
## Sandbox Pools
When you deploy an app, Miren creates a **sandbox pool** for each service. The pool manages the desired number of instances (sandboxes) for that service.
The hierarchy is:
- **App** → has one active deployment (version)
- **Sandbox Pool** → one per service, manages instance count
- **Sandbox** → individual running container
### Inspecting What's Running
Use these commands to drill down from apps to running instances:
```miren
# List all apps and their current versions
miren app list
```
```text
NAME VERSION DEPLOYED COMMIT
demo demo-vCVkjR6u7744AsMebwMjGU 1d ago 5f4dd55
conference conference-vCVkjJSe4fydvxEHfhsKfA 1d ago 5f4dd55
```
```miren
# List sandbox pools (one per service per version)
miren sandbox-pool list
```
```text
ID VERSION SERVICE DESIRED CURRENT READY
pool-CVkjTGJhRddyZDVq9CmnN demo-vCVkjR6u7744AsMebwMjGU web 1 1 1
pool-CVkjMv2R2VwcLdHJUoGKD conference-vCVkjJSe4fydvxEHfhsKfA web 3 3 3
pool-CVmuoeQCzjoNN9hGsu14c conference-vCVkjJSe4fydvxEHfhsKfA worker 2 2 2
```
```miren
# List individual sandboxes (instances)
miren sandbox list
```
```text
ID SERVICE POOL ADDRESS STATUS
demo-web-CVok1wptmHEsJ6DmTRy7g web pool-CVkjTGJhRddyZDVq9CmnN 10.8.32.9/24 running
conference-web-CVnbNhSjUbGEAC5L web pool-CVkjMv2R2VwcLdHJUoGKD 10.8.32.12/24 running
conference-web-CVnbNhVDNcqapDcX web pool-CVkjMv2R2VwcLdHJUoGKD 10.8.32.19/24 running
```
```miren
# View logs for a specific sandbox
miren logs -s demo-web-CVok1wptmHEsJ6DmTRy7g
```
## Complete Examples
### Node.js API with Worker
```toml
name = "api"
[[env]]
key = "DATABASE_URL"
value = "postgres://user:pass@postgres.app.miren:5432/api"
[services.web]
command = "node dist/server.js"
[services.web.concurrency]
mode = "auto"
requests_per_instance = 50
[services.worker]
command = "node dist/worker.js"
[services.worker.concurrency]
mode = "fixed"
num_instances = 2
[services.postgres]
image = "postgres:16"
[[services.postgres.env]]
key = "POSTGRES_PASSWORD"
value = "pass"
[[services.postgres.env]]
key = "PGDATA"
value = "/miren/data/local/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
```
### Go Service with PostgreSQL
```toml
name = "goapp"
[[env]]
key = "DATABASE_URL"
value = "postgres://goapp:changeme@postgres.app.miren:5432/goapp"
[services.web]
command = "./server"
[services.web.concurrency]
mode = "auto"
requests_per_instance = 100
scale_down_delay = "5m"
[services.postgres]
image = "postgres:16-alpine"
[[services.postgres.env]]
key = "POSTGRES_USER"
value = "goapp"
[[services.postgres.env]]
key = "POSTGRES_PASSWORD"
value = "changeme"
[[services.postgres.env]]
key = "POSTGRES_DB"
value = "goapp"
[[services.postgres.env]]
key = "PGDATA"
value = "/miren/data/local/pgdata"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
```
### Python App with Redis Queue
```toml
name = "taskqueue"
[[env]]
key = "REDIS_URL"
value = "redis://redis.app.miren:6379"
[services.web]
command = "gunicorn app:app --bind 0.0.0.0:8000"
port = 8000
[services.web.concurrency]
mode = "auto"
requests_per_instance = 20
[services.worker]
command = "rq worker --url redis://redis.app.miren:6379"
[services.worker.concurrency]
mode = "fixed"
num_instances = 3
[services.redis]
image = "redis:7-alpine"
[services.redis.concurrency]
mode = "fixed"
num_instances = 1
```
## Next Steps
- [App Configuration](./app-configuration.md) — Overview of the configuration model
- [app.toml Reference](./app-toml.md) — Complete field reference for `.miren/app.toml`
- [Traffic Routing](./traffic-routing.md) — HTTP ingress, TCP/UDP routing, multi-port services
- [Persistent Storage](./disks.md) — Local storage and disk options for databases
- [Application Scaling](./scaling.md) — Configure how each service scales
- [Getting Started](./getting-started.md) — Deploy your first app
---
## System Requirements
Miren needs a Linux server with enough memory and disk space to run its components and build your applications.
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| **Operating System** | Linux (kernel 5.10+) | |
| **Architecture** | x86_64 or arm64 | |
| **Memory** | 4 GB | 8 GB |
| **Storage** | 50 GB | 100 GB |
## Required host commands
Beyond hardware, Miren shells out to a couple of Linux networking tools that don't ship in the release bundle. They must be installed on any machine running a Miren server or runner:
| Command | Package | Used for |
|---------|---------|----------|
| `iptables` | `iptables` | The network bridge and per-sandbox NAT rules |
| `ip6tables` | `iptables` | The IPv6 bridge rules (ships with the `iptables` package) |
| `nft` | `nftables` | Services and NodePorts |
Install both with your package manager:
```bash
# Debian/Ubuntu
sudo apt install iptables nftables
# RHEL/Fedora
sudo dnf install iptables nftables
```
`miren server install` and `miren runner install` verify these are present before installing, so a missing tool stops the install with instructions rather than surfacing later as a broken network.
:::note[Extra tooling for optional features]
Some features reach for more commands, installed automatically or only when you opt in. [Block-device volumes](./managing-disk-space.md) use disk tooling (`lbdctl`, `mkfs.*`, `blkid`) when a disk is provisioned, and on SELinux-enforcing hosts the installer uses `semanage` and `restorecon` to label the binary. Both paths degrade gracefully if the tools are absent, so you only need them if you use the corresponding feature.
:::
## Why these numbers?
### Memory
Miren runs several components — containerd, etcd, buildkit, metrics, and logging — that together use around 600 MB of memory at idle. During builds, memory usage spikes as buildkit compiles your application. A single Rails app with Postgres can push total usage past 1.3 GB during deployment, which is why we set the minimum at 4 GB.
With 8 GB, you'll have comfortable headroom for running multiple apps and handling concurrent builds without things getting tight.
### Storage
Container images and build caches add up quickly. Base images for languages like Ruby or Python are 50-80 MB compressed but expand on disk, and BuildKit caches intermediate build layers aggressively — keeping up to 10 GB by default. A single Rails deployment can use 15-20 GB between base images, build cache, and the container registry. With multiple apps and their version history, usage grows from there. Miren reclaims images, caches, and old versions automatically as space gets tight — see [Managing Disk Space](./managing-disk-space.md).
Starting with 50 GB gives you enough room to get going. With 100 GB you'll have space to grow without worrying about "no space left on device" errors during builds.
## Runner nodes
The same numbers apply to every machine you add as a [distributed runner](./distributed-runners.md). `miren runner install` runs the same check against the same minimums.
The reasoning shifts a little, though. A runner doesn't build your apps or hold cluster state, so it never runs buildkit or etcd, and it won't see the build-time memory spikes that set the server's floor. What it does run is containerd and every sandbox the scheduler places on it, and it pulls and stores the images those sandboxes need. So on a runner, plan memory around how many sandboxes you expect to land there, and storage around the images they pull rather than the build cache.
## What happens if my system is too small?
The `miren server install` and `miren runner install` commands check your system against these requirements before installing. If your machine doesn't meet the minimums, the installer will let you know what's short and point you here.
If you're below the recommended thresholds but above the minimums, you'll see a heads-up but installation will proceed normally.
If you know what you're doing and want to install anyway (say, for testing), you can bypass the check:
```miren
sudo miren server install --skip-system-check
```
`miren runner install` takes the same flag, which is worth knowing if you're enrolling runners from a provisioning script that can't answer a prompt.
## We'd love to hear from you
We're still learning about system requirements as more people deploy Miren in different contexts. If you have an interesting deployment scenario or resource constraints you'd like to discuss, come chat with us on [Discord](https://miren.dev/discord)!
---
## Running Miren on a Tailnet
Your cluster doesn't need a public IP. A box on your home network, a VPS with
every inbound port closed, or a machine that only answers on
[Tailscale](https://tailscale.com/) all make perfectly good clusters. Most of
it just works, but two things change when nothing on the internet can reach
you: how your apps get certificates, and how visitors reach them.
This page covers both, plus what to expect from the dashboard.
:::info[Applies to any overlay]
Tailscale is the common case, but nothing here is specific to it. Headscale,
Nebula, ZeroTier, and plain WireGuard all behave the same way. Looking for the
reverse — hosting headscale itself as an app on Miren? See
[Run a headscale control server](./recipes/headscale.md).
:::
## Minimum working example
Install and register the cluster the way you normally would. The one required
change on a host the internet can't reach is switching app certificates to a
DNS-01 challenge:
```toml title="/etc/miren/server.toml"
[tls]
acme_email = 'you@example.com'
acme_dns_provider = 'dnsimple'
```
Put your DNS provider's API credentials in `/var/lib/miren/server/env` and
restart the server. Point your domain's DNS at the tailnet address (or use
[Miren Anywhere](./miren-cloud/miren-anywhere.md) for public traffic), and deploys
work unchanged.
## Setting up
Install Miren and register the cluster the way you normally would. There's no
special configuration for this part.
At startup your server looks at its network interfaces and reports the
addresses a client might reach it on, tailnet addresses included. When you run
`miren cluster add` from another machine on the tailnet, the CLI tries every
address it got back, in parallel, and keeps whichever answers first. Your
tailnet address is in there, so it connects.
Certificates for the API are handled for you. Miren signs them with the
cluster's own CA and includes every address it found, so connecting over the
tailnet verifies without any extra setup.
## Certificates for your apps
This is the part that needs a decision from you.
Miren gets certificates for your apps from Let's Encrypt, and the default
challenge type asks Let's Encrypt to connect to your host on port 80. On a
tailnet-only box that connection can't happen, so the certificate never
arrives.
Use a DNS-01 challenge instead. It proves you own the domain by writing a DNS
record, so nothing ever needs to reach your host:
```toml
[tls]
acme_email = 'you@example.com'
acme_dns_provider = 'dnsimple'
```
Put your provider's API credentials in the server environment file at
`/var/lib/miren/server/env`, then restart. [TLS](./tls.md) has the full list of
supported providers and the variables each one expects.
:::warning[HTTP-01 can't work here]
This isn't a preference, it's the only option. An HTTP-01 challenge needs Let's
Encrypt to reach port 80 on your host from the public internet, so on a
tailnet-only cluster it fails every time with a validation timeout.
:::
## Getting traffic to your apps
Who needs to reach your apps decides this one.
**Just you and your tailnet?** Point DNS at your tailnet address. A `100.x`
address in public DNS is fine and fairly common: it resolves for everyone but
only routes for people on your tailnet. Tailscale's
[MagicDNS](https://tailscale.com/kb/1081/magicdns/) works too if you'd rather
not publish anything at all.
**The whole internet?** Use [Miren Anywhere](./miren-cloud/miren-anywhere.md). It
carries app traffic through the Miren POP network and reaches your cluster over
the connection your cluster already makes outbound, so you don't need a public
address or any open ports. On a cluster with no public address, it turns on by
default.
:::note[Deploys still go direct]
Miren Anywhere carries app traffic only. Deploys, logs, and everything else the
CLI does talk straight to your cluster on UDP 8443, so you deploy from a machine
on the tailnet.
:::
## What the dashboard shows
A tailnet-only cluster reads **Online**, with the control plane **not
reachable** and apps reachable through Miren Anywhere. Only the middle one looks
alarming, and it isn't: it means Miren Cloud can't open a connection from the
public internet, which is exactly what you set up. See
[Cluster Connectivity](./miren-cloud/connectivity.md) for what each of the three
checks actually measures.
## Troubleshooting
**Start with `miren debug advertise`.** Run it on the host and it prints every
address your server found, along with why it kept or dropped each one. That's
usually faster than guessing:
```bash
sudo miren debug advertise
```
Addresses on container bridges like `docker0` and Miren's own `rt0` get dropped
on purpose, since they only ever route to workloads on the same host.
**Pin an address by hand** when discovery gets it wrong, like a host behind a
static NAT. Anything in `additional_ips` skips discovery entirely and is always
advertised:
```toml
[tls]
additional_ips = ["100.64.0.10"]
```
Advertisement is worked out at startup, so restart the server after changing
it.
**If connections time out,** first make sure you're on a current release.
Miren v0.12.1 and earlier couldn't complete a QUIC handshake through a tunnel
at all, which made tailnets unusable. After that, check the server is actually
listening with `sudo ss -lunp | grep 8443`, and remember that Tailscale ACLs
are enforced by `tailscaled` itself, so a permissive host firewall won't help
if an ACL is blocking the port.
---
## Tasks
A **service** is a process Miren keeps up. A **task** is a command Miren knows how to run.
Migrations, backfills, cleanup jobs, reindexes, and one-off scripts are tasks. Before tasks existed, these had to be smuggled in somewhere — a goroutine inside the app, a "worker" service that was really a sleep loop, or an external cron hitting an endpoint you built for the purpose. Those work until they don't, and when they stop you usually find out from the data rather than from the platform.
Each run of a task gets a fresh sandbox built from your app's deployed image and config, runs one command, records an exit code, and is torn down.
## Declaring a task
```toml
[tasks.migrate]
command = "bin/rails db:migrate"
trigger = "deploy"
timeout = "10m"
```
See the [`app.toml` reference](./app-toml.md#tasks) for every field.
A task always runs in your app's image and sees the same environment your services do, including credentials injected by [addons](./addons.md) — which is what makes the migration case work without extra configuration.
## Triggers
A task's `trigger` says what starts it.
| `trigger` | Starts when |
|-----------|-------------|
| `deploy` | A new version is deployed, before it takes traffic |
| `schedule` | A calendar expression comes due |
| `manual` | Only when you ask (the default) |
Any task can be invoked by hand whatever its trigger — that's how you re-run a migration without redeploying.
### `deploy` — run before the new version goes live
```toml
[tasks.migrate]
command = "bin/rails db:migrate"
trigger = "deploy"
```
The deploy waits. If the task fails, **the deploy fails** and your previous version keeps serving — nothing was brought up, so there is nothing to roll back. If it succeeds, the new version takes traffic.
Deploy tasks run after your addons are ready, so a migration has its database. Several of them run at the same time; ordering between tasks isn't expressible yet.
:::warning[Your previous version is running while the task is]
The gate sits before the new version takes traffic, but the task itself runs on the **new** image. So a migration executes against your database while the **old** code is still serving every request.
Your schema changes have to be compatible with both versions at once — expand-contract, or an equivalent. This is the normal path, not an edge case.
:::
:::note[Rollbacks]
Rollbacks don't run deploy tasks. Activating a version that already exists would re-run a migration against a database that's already migrated forward, and Miren doesn't run down-migrations. Reversibility is your app's to design.
:::
### `schedule` — run on a timer
```toml
# Every six hours, at 00:00, 06:00, 12:00 and 18:00 UTC
[tasks.cleanup]
command = "bin/cleanup-sessions"
trigger = "schedule"
every = "6h"
# Mondays at 9am
[tasks.report]
command = "bin/weekly-report"
trigger = "schedule"
schedule = "Mon *-*-* 09:00:00"
```
`every` and `schedule` are two spellings of the same thing and are mutually exclusive. `every` is sugar: it's converted to a calendar expression when your config is read, so that's what `miren app runs` shows you.
Scheduling has three behaviors worth knowing before you rely on it:
:::note[Ticks don't queue]
If a run is still going when the next firing comes due, that firing is **skipped** — and recorded as skipped, so `miren app runs` shows it. A job that has grown slower than its interval looks the same as a job that stopped running, unless the platform says which.
:::
:::note[Missed firings aren't replayed]
If the cluster was down when a firing came due, it's skipped rather than run on recovery. Replaying an outage's worth of jobs is a good way to turn an outage into an incident. If you need catch-up, make the job idempotent over a window.
:::
:::note[Each firing runs once]
Once across the whole cluster, without you configuring anything.
:::
### `manual` — run when you ask
```toml
[tasks.reindex]
command = "bin/reindex"
timeout = "4h"
```
```bash
miren app run --task reindex
```
## Running and watching tasks
```bash
miren app run --task reindex # run it, attached to your terminal
miren app run --task reindex --detach # start it, get an id back
miren app runs # what has run, and how it ended
miren app attach run/myapp-reindex-4kq # join a run already going
miren logs run run/myapp-reindex-4kq # read a run's output
miren app runs cancel run/myapp-... # stop a run early
```
### Disconnecting is not cancelling
A run belongs to the platform, not to your terminal. If your connection drops, the command keeps going — reattach with `miren app attach`, read its output with `miren logs run`, or leave it and let its `timeout` reap it.
That gives `--detach` exactly one meaning: *don't attach my terminal right now*. To actually stop a run, use `miren app runs cancel`.
## Apps that are only tasks
An app doesn't need a long-running process at all:
```toml
name = "batch-jobs"
web = false
[tasks.reindex]
command = "bin/reindex"
```
`web = false` says so out loud. Without it, Miren would synthesize a web service from your image's entrypoint and keep it running forever — so declaring tasks with no services and no `web` setting is a build error rather than a guess.
Deploying such an app selects or builds its image, provisions addons, and runs any deploy-triggered tasks. No long-running service starts, so nothing runs or is billed for compute between task invocations. `miren app list` reports it as `ready` — deployed and available to invoke, as opposed to `idle`, which means an app that scaled itself to zero.
## Migrating from `[services.console]`
Declaring `[services.console]` used to get you two things: a long-running service Miren kept up, and the command `miren app run` executed. Only the second was ever useful.
Rename it:
```toml
[tasks.console]
command = "bin/rails console"
```
Until you do, Miren treats a `[services.console]` block as a task and warns at build time. That's the behavior you wanted anyway — the service was costing you a sandbox nobody used.
## Current limitations
- **A task cannot reach a Miren disk.** If your app keeps data on a [disk](./disks.md), a task can't see it — not even the disk belonging to a service in the same app. Miren disks are single-writer, and a task running beside the service that holds the lease would either block or race it, so runs skip disks entirely.
This one is quiet: nothing warns you, and your program reports its own "no such file or directory". A backfill over disk-backed data isn't expressible yet — reach that data through an addon, or do the work inside the service that owns the disk.
- **No ordering between deploy tasks.** Every deploy task starts at once, and the deploy waits for all of them. If one migration has to land before another, put both in a single task and order them yourself.
- **A task runs in the app's image.** No per-task image. Anything your addons expose is available, since those arrive as environment variables.
---
## Terminology
Common terms used in Miren.
## Addon
A managed backing service that Miren provisions and operates for your app. Addons handle infrastructure setup and inject connection credentials as environment variables automatically. Available addons include PostgreSQL, MySQL, Valkey, RabbitMQ, and Memcache. See [Addons](./addons.md).
## App
An application deployed to Miren. Each app has a name, configuration, and one or more deployments.
## app.toml
The configuration file for a Miren application, located at `.miren/app.toml`. Defines the app's services, environment variables, build settings, scaling behavior, disks, and addons. See [app.toml Reference](./app-toml.md).
## Build
The process of creating a container image from your application source code. Miren detects your language and framework, runs build steps (including any [onbuild](#onbuild) commands), and produces an image that runs in a [sandbox](#sandbox). Builds are powered by BuildKit.
## Client Config
The configuration file (`~/.config/miren/clientconfig.yaml`) that stores your CLI settings, including cluster connections and the active cluster. Managed automatically by commands like `miren server install` and `miren cluster add`.
## Cluster
A Miren server instance that runs your applications. A cluster can be standalone or connected to Miren Cloud for team management and multi-environment workflows.
## Cold Start
The additional latency on the first request to an app that has been [scaled to zero](#scale-to-zero) or needs a new [sandbox](#sandbox). Miren creates a sandbox, starts the process, and waits for it to bind its port before forwarding the request. Subsequent requests are served immediately.
## Concurrency
The scaling configuration for a [service](#service). Miren supports two modes: **auto** (scales instances up and down based on traffic) and **fixed** (runs a constant number of instances). Auto mode is configured with `requests_per_instance` and `scale_down_delay`; fixed mode uses `num_instances`. See [Application Scaling](./scaling.md).
## Coordinator
The primary node in a distributed cluster — the machine running `miren server`. The coordinator holds the cluster's state (entity store and etcd), image registry, and workload-identity signing, and schedules sandboxes across the cluster. It also runs workloads itself. Every [distributed runner](#distributed-runner) depends on the coordinator being reachable. See [Distributed Runners](./distributed-runners.md).
## Default Route
The [route](#route) automatically assigned to an app when it is first deployed. Points to the cluster's hostname (e.g., `myapp.cluster-abc123.miren.systems`), giving the app a URL without any DNS configuration.
## Deployment
An attempt to select or build an image and release a specific [version](#version) of your app. Running `miren deploy` starts a deployment, which gets its own ID. A successful deployment produces a new [version](#version); a failed deployment produces no version but remains as a record of what was attempted and why it failed. A [rollback](#rollback) is a deployment that re-releases an existing version without selecting or building another image.
A deployment and the version it produces are distinct objects: the deployment is the action, and the version is the resulting artifact.
## Disk
Persistent storage attached to your application. Miren disks survive restarts and redeployments, making them suitable for databases and stateful workloads. See [Persistent Storage](./disks.md).
## Distributed Runner
An additional machine that joins a cluster to host [sandboxes](#sandbox), expanding capacity beyond the [coordinator](#coordinator). A runner joins with `miren runner join`, runs stateless workloads scheduled to it, and reports health back to the coordinator, but holds no cluster state of its own. See [Distributed Runners](./distributed-runners.md).
## Ephemeral Version
A labeled, time-boxed preview build of your app that runs alongside the active [version](#version) on its own subdomain. Ephemeral versions don't affect production traffic and are deleted automatically when their [TTL](#ttl) expires. Used for pull request previews. See [Pull Request Environments](./pr-environments.md).
## Identity Provider
An OIDC-compliant authentication service (e.g., Google, GitHub, Okta) registered with your cluster. Identity providers are used for [route protection](#route-protection) (SSO in front of your app) and [OIDC bindings](#oidc-binding) (CI/CD deployment without stored secrets). Configured with `miren auth provider add`.
## Instance
A single running copy of a [service](#service). Each instance runs in its own [sandbox](#sandbox). The number of instances is controlled by the service's [concurrency](#concurrency) configuration and managed by the [sandbox pool](#sandbox-pool).
## Label
A DNS-compliant name assigned to an [ephemeral version](#ephemeral-version) (e.g., `pr-123`). The label becomes the subdomain prefix for accessing the preview: `.`.
## Miren Cloud
A central control plane that connects and manages your Miren clusters. Provides team management, access control, and multi-environment workflows. See [Miren Cloud](./miren-cloud/overview.md).
## Miren Runtime
The core container orchestration system that powers Miren. Built on containerd, it handles building, deploying, and running your applications in isolated sandboxes.
## Miren Runtime Client
The `miren` CLI tool used to interact with your cluster. Manages apps, deployments, routes, and cluster configuration.
## Miren Server
The background service that runs on your cluster and manages applications, sandboxes, and networking. Installed as a systemd service via `miren server install`.
## Node Port
A port exposed directly on the host machine for non-HTTP services like game servers, IRC, or custom TCP/UDP protocols. Unlike HTTP traffic (which is routed automatically through Miren's ingress layer), node ports give external clients a direct `host:port` to connect to. Configured with `port_type = "tcp"` in [app.toml](#apptoml). See [Traffic Routing](./traffic-routing.md#non-http-services-tcpudp).
## OIDC Binding
A configuration that links an [app](#app) to an [identity provider](#identity-provider) with a subject pattern, enabling CI/CD systems to deploy using short-lived OIDC tokens instead of stored secrets. For example, a binding can authorize GitHub Actions workflows from a specific repository to deploy a specific app. See [CI/CD Deployment](./ci-deploy.md).
## Onbuild
Commands defined in the `[build]` section of [app.toml](#apptoml) that run inside the build container after the main build steps. Used for tasks like asset compilation (`npm run build`) or pruning dev dependencies. Equivalent to Dockerfile `ONBUILD` instructions but configured declaratively.
## Procfile
A simple file format for defining [services](#service) in your app. Each line maps a service name to a command (e.g., `web: node server.js`). For more advanced configuration (scaling, disks, images), use [app.toml](#apptoml) instead.
## Route
Maps a hostname to an application. Routes determine how HTTP traffic reaches your apps. Your first app gets a [default route](#default-route) automatically. See [Traffic Routing](./traffic-routing.md).
## Rollback
A type of [deployment](#deployment) that re-releases an existing [version](#version). Because the container image is already resolved, a rollback skips image selection and building, then releases the target version as-is.
## Route Protection
Authentication at the routing layer using an [identity provider](#identity-provider). Unauthenticated requests are redirected to an OIDC provider for login. After authentication, JWT claims are injected as HTTP headers (e.g., `X-User-Email`) before the request reaches your app — no in-app auth code required. See [Protecting Routes](./route-protect.md).
## Sandbox
An isolated execution environment where your app runs. Sandboxes are Linux containers managed by containerd, isolated using kernel namespaces and cgroups, and each has its own network namespace by default.
## Sandbox Pool
The controller that manages the set of [instances](#instance) for a [service](#service) within a [version](#version). The pool tracks desired, current, and ready instance counts and reconciles them based on the service's [concurrency](#concurrency) configuration. Visible via `miren sandbox-pool list`.
## Scale to Zero
Miren's ability to scale a service down to zero [instances](#instance) when there is no traffic. The next incoming request triggers a [cold start](#cold-start) to create a new sandbox. Enabled by default for auto-scaled services. See [Application Scaling](./scaling.md#scale-to-zero).
## Service
A named process within an app. An app can have multiple services, each with its own command, image, port, and scaling configuration. Common services include `web` (HTTP server), `worker` (background jobs), and database services like `postgres`. See [Services](./services.md).
## TLS Certificate
An SSL/TLS certificate that Miren provisions automatically from [Let's Encrypt](https://letsencrypt.org/) when you set a [route](#route). Certificates are obtained via ACME HTTP-01 or DNS-01 challenges, cached on disk, and renewed automatically. See [TLS Certificates](./tls.md).
## TTL
Time to live — the expiration timer for an [ephemeral version](#ephemeral-version). After the TTL elapses, the ephemeral version is automatically deleted. Defaults to 24 hours and can be overridden with `--ttl` on deploy.
## Version
A unique identifier for a runnable release of your app (e.g., `myapp-vCVkjR6u7744AsMebwMjGU`). Each successful [deployment](#deployment) creates a new version, which tracks the container image, git commit, and configuration. Previous versions can be rolled back to without selecting or building another image. See [rollback](#rollback).
## WAF
Web Application Firewall — per-route request filtering using the [OWASP Core Rule Set](https://coreruleset.org/). Inspects incoming HTTP requests for common attacks like SQL injection, XSS, and path traversal. Configured per [route](#route) with a paranoia level (1–4) controlling strictness. See [WAF](./waf.md).
## Wildcard Route
A [route](#route) that matches all subdomains of a domain using a `*` prefix (e.g., `*.myapp.example.com`). Useful for multi-tenant apps or custom subdomain routing. Exact routes take priority over wildcards when both match. See [Traffic Routing](./traffic-routing.md#wildcard-routes).
---
## TLS Certificates
Miren automatically provisions TLS certificates for your applications using [Let's Encrypt](https://letsencrypt.org/). By default, no configuration is needed — when you set a route for your app, Miren obtains a certificate automatically.
## Minimum working example
There's nothing to configure — set a route and HTTPS follows:
```miren
miren route set myapp.example.com myapp
```
The first request to the hostname triggers certificate provisioning from Let's Encrypt; renewal is automatic. If your server isn't reachable from the public internet, switch to a [DNS-01 challenge](#dns-01-challenge):
```toml title="/etc/miren/server.toml"
[tls]
acme_dns_provider = "cloudflare"
acme_email = "you@example.com"
```
The DNS provider also needs its API credential in the Miren server's environment (for Cloudflare, `CF_DNS_API_TOKEN`) — the [DNS-01 section](#dns-01-challenge) shows how to wire it up.
## How It Works
When a request arrives for a hostname with a configured route, Miren provisions a TLS certificate from Let's Encrypt using the ACME protocol. Certificates are cached on disk and renewed automatically before they expire.
:::warning[Unrouted hostnames]
Hostnames without a configured route are served with a self-signed fallback certificate (browsers will show a warning).
:::
A name one level under a route, such as `foo.myapp.example.com` under a wildcard route `*.myapp.example.com` or `pr-123.myapp.example.com` under `myapp.example.com`, gets a certificate on its first request only if something vouches for it: a live [ephemeral deploy](./pr-environments.md) with that label, or the route's app answering its TLS check. Otherwise it gets the fallback certificate. Wildcard DNS sends every name to your cluster, so without this check anyone could make Miren request a certificate for any name they liked. See [Certificates for wildcard subdomains](./traffic-routing.md#certificates-for-wildcard-subdomains).
## Challenge Types
Let's Encrypt needs to verify that you control the domain before issuing a certificate. Miren supports two verification methods.
### HTTP-01 Challenge (Default)
The default method. Let's Encrypt makes an HTTP request to your server on port 80 to verify domain ownership.
**Requirements:**
- Your server must be reachable from the public internet on ports 80 and 443
- DNS for your domain must point to the server's public IP
This is the zero-configuration option — it works out of the box with `miren server install`.
### DNS-01 Challenge
If your server isn't publicly reachable (e.g., it's behind a VPN like Tailscale, on a private network, or doesn't have ports 80/443 open to the internet), you can use DNS-01 challenges instead. This method proves domain ownership by creating a DNS TXT record, so it works regardless of whether your server is reachable from the internet.
Miren uses [lego](https://go-acme.github.io/lego/) for DNS challenges, which supports [90+ DNS providers](https://go-acme.github.io/lego/dns/).
#### Configuration
Add the DNS provider and ACME email to your server config file:
```toml title="/etc/miren/server.toml"
[tls]
acme_dns_provider = "dnsimple"
acme_email = "you@example.com"
```
Each DNS provider requires credentials passed via environment variables. Create an environment file for the Miren service:
```bash title="/var/lib/miren/server/env"
DNSIMPLE_OAUTH_TOKEN=your-token-here
```
Then update the systemd service to load the environment file and config:
```bash
sudo systemctl edit miren --force
```
Add the environment file:
```ini
[Service]
EnvironmentFile=/var/lib/miren/server/env
```
Restart to pick up the changes:
```bash
sudo systemctl restart miren
```
#### Provider Examples
Each provider needs different environment variables. Here are a few common ones:
**DNSimple:**
```toml
acme_dns_provider = "dnsimple"
```
```bash
DNSIMPLE_OAUTH_TOKEN=your-token
```
**Cloudflare:**
```toml
acme_dns_provider = "cloudflare"
```
```bash
CF_DNS_API_TOKEN=your-token
```
**Route 53 (AWS):**
```toml
acme_dns_provider = "route53"
```
```bash
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_REGION=us-east-1
```
See the [lego DNS provider documentation](https://go-acme.github.io/lego/dns/) for the full list of supported providers and their required environment variables.
## Ingress Modes and TLS
Whether Miren terminates TLS at all (and on which ports) is set by `ingress.mode`. The default `tls-autoprovision` mode is what this page has been describing: TLS on `:443`, plus `:80` for the HTTPS redirect and HTTP-01 ACME challenges.
Two other modes are available for deployments where Miren sits behind a TLS-terminating proxy (nginx, Caddy, Cloudflare Tunnel, `tailscale serve`, ALB). This is also the answer when something else already owns `:443` on the host: rather than fighting over the port, let that proxy keep it and run Miren behind it with `behind-proxy-http`.
| Mode | What Miren does | Cert source |
|------|-----------------|-------------|
| `tls-autoprovision` (default) | Binds `:443` for TLS and `:80` for redirect / HTTP-01 ACME | `[tls]` (ACME or self-signed) |
| `behind-proxy-http` | Plain HTTP at the configured address (default `127.0.0.1:80`); TLS lives at the proxy | n/a (proxy terminates TLS) |
| `behind-proxy-https` | TLS terminated at the configured address (default `127.0.0.1:443`); no `:80` listener, so no HTTP-01 ACME | `[tls]` self-signed or DNS-01 ACME only |
Under `behind-proxy-http`, Miren learns the visitor's original scheme from the proxy's `X-Forwarded-Proto` (or RFC 7239 `Forwarded`) header, and uses it to decide whether auth cookies are marked `Secure` and what `X-Forwarded-Proto` your app receives. It selects the visitor address for access logs from `X-Forwarded-For`, counting from the right past `ingress.trusted_proxy_hops` trusted proxies (one by default). In the other two modes Miren terminates TLS itself, so it ignores those headers on inbound requests and goes by the connection. Traffic arriving through a [Miren Anywhere](./miren-cloud/miren-anywhere.md) POP is always https, since the POP terminates TLS for visitors.
:::warning[`behind-proxy-http` trusts the proxy, so lock it down]
The proxy must set `X-Forwarded-Proto` and overwrite any value the client sent. If the listener is bound anywhere other than loopback, a firewall must limit it to the proxy's addresses; see [`[ingress]`](./server-config.md#ingress) for why.
:::
See [Server Configuration Reference → `[ingress]`](./server-config.md#ingress) for the full schema. The HTTP-01 ACME flow described above only applies under `tls-autoprovision`; under `behind-proxy-https`, certs must come from DNS-01 ACME or be self-signed because Miren doesn't bind `:80` in that mode (and the public DNS for the hostname points at the proxy anyway, not at Miren).
## TLS Settings Reference
All TLS settings live under the `[tls]` section of the server config file (typically `/etc/miren/server.toml` — see [Server Configuration](./server-config.md#config-file) for the search order). The three ingress-cert settings below are consulted only under TLS-terminating ingress modes:
| Setting | CLI Flag | Description |
|---------|----------|-------------|
| `acme_email` | `--acme-email` | Email for Let's Encrypt account registration and expiry notifications |
| `acme_dns_provider` | `--acme-dns-provider` | DNS provider name for DNS-01 challenges (e.g., `cloudflare`, `route53`, `dnsimple`) |
| `self_signed` | `--self-signed-tls` | Use a self-signed cert instead of ACME (development, or behind a non-verifying TLS proxy) |
Two additional `[tls]` settings apply to the API server and etcd certs rather than ingress, so they're valid under any ingress mode:
| Setting | CLI Flag | Description |
|---------|----------|-------------|
| `additional_names` | `--dns-names` | Extra DNS names appended to the API server and etcd cert SANs. Under `tls-autoprovision`, these can also get an ingress cert (see below) |
| `additional_ips` | `--ips` | Extra IPs appended to the API server and etcd cert SANs, and forced into the advertised address list |
:::info[`additional_names` and the ingress cert]
Under `tls-autoprovision` with HTTP-01 challenges, Miren treats each
`additional_names` entry as a name the server answers to as itself. It gets an
ACME certificate without needing a route, as soon as its DNS points at the
cluster. That's what lets a self-hosted cluster serve its
[workload identity](./workload-identity.md) issuer over valid TLS. IPs,
single-label names, wildcards, and reserved suffixes like `.internal` and
`.local` are skipped.
With `acme_dns_provider` set, only the first entry gets an ingress cert, and
only when it's the workload identity issuer. DNS-01 can issue for a name even
when it doesn't point at the cluster, so the rest stay on the API and etcd
certs rather than being sent to Let's Encrypt.
:::
:::tip[`additional_ips` also controls advertisement]
Addresses in `additional_ips` bypass the filtering applied to auto-discovered
addresses and are always advertised to Miren Cloud, which makes this the lever
for a cluster behind a static NAT or on a private network. See
[Running Miren on a Tailnet](./tailscale.md).
:::
## Troubleshooting
### Certificate Not Provisioning
Check the server logs for ACME errors:
```bash
sudo journalctl -u miren | grep -i acme
```
**HTTP-01 challenges:** Ensure ports 80 and 443 are reachable from the public internet. See [Firewall Configuration](./firewall.md) for cloud provider-specific guidance.
**DNS-01 challenges:** Verify your DNS provider credentials are correct and the environment file is loaded:
```bash
sudo systemctl show miren | grep EnvironmentFile
```
### Wrong Certificate / Self-Signed Warning
If you're seeing a self-signed certificate warning for a domain that should have a real certificate, check that a route is configured for that hostname:
```miren
miren route
```
Miren provisions ACME certificates for hostnames with configured routes, names that something vouches for under those routes, and the server's own names (its Miren Cloud hostname and its `additional_names`, as described above). All other hostnames get the self-signed fallback.
---
## Traffic Routing
Miren handles getting traffic to your app. For HTTP apps, this works automatically — deploy and add a route. For non-HTTP services like IRC servers, game servers, or custom TCP/UDP protocols, you configure ports explicitly.
## Minimum working example
Map a hostname to your app:
```miren
miren route set myapp.example.com myapp
```
Requests to that hostname now reach your `web` app service, with TLS provisioned automatically. Omit `--service` to keep using `web`. For a non-HTTP service, expose a port directly in `.miren/app.toml`:
```toml
[[services.irc.ports]]
port = 6667
name = "irc"
type = "tcp"
node_port = 6667
```
## Web Traffic (HTTP)
When you deploy an app, the `web` service automatically receives HTTP traffic. No port configuration is needed — Miren handles TLS, routing, and load balancing.
### Adding a Route
Map a hostname to your app:
```miren
miren route set myapp.example.com myapp
```
Requests to that hostname are forwarded to your `web` app service. TLS certificates are provisioned automatically (see [TLS Certificates](./tls.md)).
### Routing to another HTTP app service
A hostname can target one named HTTP-capable app service. Use `--service` when the process you want to expose is not named `web`:
```miren
miren route set api.myapp.example.com myapp --service api
```
The selected app service must be present in the active configuration and expose an HTTP port. Omitted port types default to `"http"`, and the older scalar `port` field counts as HTTP unless its `port_type` says otherwise. Each hostname still has one target service; Miren does not select a service automatically or route paths to different services.
### Wildcard Routes
Route all subdomains of a domain to a single app using a wildcard:
```miren
miren route set '*.myapp.example.com' myapp
```
A wildcard route `*.myapp.example.com` matches any subdomain like `foo.myapp.example.com` or `bar.myapp.example.com`. It does **not** match the bare domain `myapp.example.com` — add a separate route for that if needed.
If an exact route also exists for a specific subdomain, the exact route takes priority. For example, if you have both `*.myapp.example.com` and `api.myapp.example.com`, requests to `api.myapp.example.com` use the exact route while all other subdomains use the wildcard.
The wildcard `*` must be the first label — patterns like `foo.*.example.com` are not supported. The domain must have at least two labels after the wildcard (e.g., `*.example.com` is valid, `*.com` is not).
For [Pull Request Environments](./pr-environments.md), you don't need a wildcard route — any subdomain of an existing route automatically resolves to its ephemeral label. You only need wildcard *DNS* pointing at your cluster.
#### Certificates for wildcard subdomains
A wildcard route serves every subdomain, but Miren won't request a Let's Encrypt certificate for a subdomain until your app says the name is real. Without that check, anyone could make your cluster request a certificate for any name under your domain. Scanners do this constantly, trying `admin`, `mail`, `database`, and thousands of other guesses. Each guess would cost you rate limit and add a public Certificate Transparency entry for a name you don't run.
To let your app vouch for its own subdomains, give the route a TLS check path:
```miren
miren route tls-check '*.myapp.example.com' /tls-check
```
Before issuing a certificate for a new name, Miren sends `GET /tls-check?domain=` to your app. A `200` means go ahead. Any other answer, or no answer within a few seconds, means the name gets the fallback certificate. The request skips the route's authentication, so the endpoint has to be publicly reachable. Miren remembers a yes for a few minutes and a no for about 30 seconds, so a handshake doesn't wait on your app every time and a preview that comes up just after its name was probed isn't stuck for long. A `5xx` or a timeout isn't remembered, so an app still waking up from zero gets asked again on the next connection.
Without a TLS check, subdomains under a wildcard route get the fallback certificate, except for live ephemeral deploys, which always get a real one. If you only have a handful of real subdomains, you can skip the check and add an exact route for each one instead.
### Custom Domains
To put your own domain in front of an app on Miren, point DNS at your cluster and set a route. TLS provisions automatically.
**Find your cluster's hostname.** Clusters registered with Miren Cloud get a hostname under `miren.systems` — something like `cluster-jwomf2l0tn8z.miren.systems`. You'll find yours on the cluster page in [miren.cloud](https://miren.cloud). If you're running standalone without cloud, use your server's public hostname or IP.
**Point DNS at your cluster.** For most setups, point both the apex and a wildcard so you can add or change routes later without touching DNS again:
```text
yourdomain.com. ALIAS cluster-jwomf2l0tn8z.miren.systems.
*.yourdomain.com. CNAME cluster-jwomf2l0tn8z.miren.systems.
```
Most DNS providers don't allow a CNAME at the apex (`yourdomain.com` with no subdomain). Use an ALIAS or ANAME record where your provider supports it, or an A record pointing at your cluster's IP. The wildcard record can always be a CNAME.
If you only need one hostname on Miren, a single CNAME for that name is fine on its own:
```text
app.yourdomain.com. CNAME cluster-jwomf2l0tn8z.miren.systems.
```
**Then set a route.** Map any host that resolves to your cluster to an app:
```miren
miren route set app.yourdomain.com myapp
```
If you set up wildcard DNS above, you can also send every subdomain to the same app with a [wildcard route](#wildcard-routes):
```miren
miren route set '*.yourdomain.com' myapp
```
You don't have to do anything else for HTTPS — Miren provisions Let's Encrypt certificates as requests arrive. See [TLS Certificates](./tls.md) for the details.
### Request Timeouts
Miren gives up on a proxied request once it has gone quiet for 60 seconds — nothing from your app, nothing to send. That covers most apps, but it cuts off patterns that are silent by design: long polling, an endpoint that waits on a slow job before writing its first byte, or a report that takes minutes to assemble.
Raise the limit for just that route:
```miren
miren route timeout longpoll.yourdomain.com 10m
```
Check what a route is using, and put it back on the cluster default:
```miren
miren route timeout longpoll.yourdomain.com
miren route timeout longpoll.yourdomain.com --clear
```
The value is a duration string such as `10m`, `300s`, or `2h`, and must be positive. `miren route list` shows a `TIMEOUT` column with each route's override, or `-` when it uses the default.
The 60-second default itself is the `server.http_request_timeout` setting — see [Server Configuration](./server-config.md). A per-route value overrides it for that route only.
:::note[Streaming responses are already fine]
The timeout measures silence, not total duration. A server-sent-events stream or a WebSocket that keeps sending data will run indefinitely on the default — you only need an override when the connection genuinely goes quiet for longer than a minute.
:::
### Choosing a Port
Miren sets the `PORT` environment variable to tell your app which port to listen on. Your app should bind to `PORT`:
```toml
[services.web]
command = "node server.js"
# App reads process.env.PORT and listens there
```
To choose a specific port, set it in `.miren/app.toml`:
```toml
[services.web]
command = "gunicorn app:app --bind 0.0.0.0:8000"
port = 8000
```
Miren sets `PORT=8000` and routes traffic there.
:::tip[Binding the wrong port]
The most common deploy snag when porting an existing app is a hardcoded port that ignores `$PORT`. If your app listens on, say, `:8080` while Miren expects `:3000`, Miren now notices the mismatch: when the configured port never comes up but the app is clearly listening somewhere else, it routes to the port the app actually bound and logs a note on the sandbox (`miren logs sandbox `) telling you which port that was. It's a safety net, not a substitute for config — set `port` (or read `$PORT`) so the expected and actual ports line up.
Two things still trip this up. If your app binds to `127.0.0.1` instead of `0.0.0.0`, Miren can't reach it from outside the container no matter the port, so bind to all interfaces. And if the app opens several ports and none of them is the configured one, Miren won't guess which to route to — it fails with a message naming the candidates so you can pick the right one in `app.toml`.
:::
## Non-HTTP Services (TCP/UDP)
To expose services that don't speak HTTP — an IRC server, a game server, a custom protocol — use the `ports` array in `.miren/app.toml` with a `node_port` to make the port reachable from outside the cluster.
```toml
[services.irc]
command = "./ircd"
[[services.irc.ports]]
port = 6667
name = "irc"
type = "tcp"
node_port = 6667
[[services.irc.ports]]
port = 6697
name = "irc-tls"
type = "tcp"
node_port = 6697
```
With this config:
- The IRC server listens on ports 6667 and 6697 inside its container
- Clients connect to those same ports on your Miren host
- Miren forwards the traffic and load-balances across instances
### Port Configuration
Each entry in `ports` accepts:
| Field | Required | Description | Default |
|-------|----------|-------------|---------|
| `port` | Yes | Port your process listens on (1–65535) | — |
| `name` | Yes | A unique name for this port | — |
| `type` | No | `"http"` for web traffic, `"tcp"` for raw TCP, `"udp"` for UDP | `"http"` |
| `node_port` | No | Port to expose on the host machine (1–65535) | (none) |
### Node Ports
The `node_port` is what makes a non-HTTP service reachable from outside. Without it, the port is only accessible to other services within the app.
Node port constraints:
- Must be unique across all apps on the cluster — Miren checks for conflicts at deploy time
- Cannot overlap with ports Miren uses (80, 443, 8443)
If your cloud provider uses security groups or network ACLs, you'll need to allow traffic on your node ports (see [Firewall Configuration](./firewall.md)).
## Multiple Ports per Service
A single service can expose a mix of HTTP and non-HTTP ports. This is useful when a service needs an HTTP endpoint for health checks or an API alongside a raw protocol port:
```toml
[services.app]
command = "./server"
# HTTP endpoint for health checks and API
[[services.app.ports]]
port = 3000
name = "http"
type = "http"
# TCP port for the data protocol
[[services.app.ports]]
port = 7000
name = "data"
type = "tcp"
node_port = 7000
```
The HTTP port is routed through Miren's web traffic pipeline (routes, TLS, autoscaling). The TCP port is exposed directly via the node port. Both reach the same running process.
### Same Port, Different Protocols
Some protocols need both TCP and UDP on the same port number. Declare them as separate entries:
```toml
[[services.dns.ports]]
port = 53
name = "dns-udp"
type = "udp"
node_port = 53
[[services.dns.ports]]
port = 53
name = "dns-tcp"
type = "tcp"
node_port = 5353
```
Port numbers can repeat as long as each port/type pair is unique within the service (since `"tcp"` and `"udp"` use different transport protocols).
### The `PORT` Environment Variable
When using the `ports` array, Miren sets `PORT` to:
1. The first port with `type = "http"`, if one exists
2. Otherwise, the first port in the array
:::warning[PORT is managed by Miren]
`PORT` is managed by Miren and can't be overridden.
:::
## Service-to-Service Communication
Services within the same app can talk to each other using internal DNS. Each service is reachable at `.app.miren`:
```toml
name = "myapp"
[[env]]
key = "DATABASE_URL"
value = "postgres://user:pass@postgres.app.miren:5432/mydb"
[services.web]
command = "node server.js"
[services.postgres]
image = "postgres:16"
[services.postgres.concurrency]
mode = "fixed"
num_instances = 1
```
Internal traffic goes directly between containers — it doesn't pass through Miren's routing layer. Services connect on their standard ports (5432 for PostgreSQL, 6379 for Redis) without any Miren port configuration needed.
:::note[Internal DNS scope]
Internal DNS only works between services in the same app. Cross-app communication is not currently supported.
:::
## Simple Port Configuration
If your service only has one port, you can use the simpler single-field syntax instead of the `ports` array:
```toml
[services.web]
port = 8000
port_name = "http"
port_type = "http"
```
| Field | Description | Default |
|-------|-------------|---------|
| `port` | Port your process listens on | Set by `PORT` env var |
| `port_name` | Name for the port | Service name |
| `port_type` | `"http"` or `"tcp"` | `"http"` |
:::warning[Port field restrictions]
You can't mix these fields with the `ports` array on the same service.
:::
## How It Works Under the Hood
:::info[For the curious]
You don't need to understand these details to use Miren. This section is for the curious.
:::
**HTTP traffic** flows through Miren's HTTP ingress — a reverse proxy that listens on ports 80 and 443. When a request arrives, the ingress looks up the hostname to find the matching app, ensures a sandbox is running, and proxies the request to the sandbox's HTTP port.
**Non-HTTP traffic** is routed by the kernel using nftables NAT rules. When you deploy a service with non-HTTP ports, Miren allocates an internal IP for the service and programs firewall rules that forward matching traffic (by port and protocol) to the running sandboxes. As instances scale up and down, these rules are updated automatically to load-balance across available endpoints.
## Examples
### IRC Server
```toml
name = "irc"
[services.irc]
command = "./ircd"
[[services.irc.ports]]
port = 6667
name = "irc"
type = "tcp"
node_port = 6667
[[services.irc.ports]]
port = 6697
name = "irc-tls"
type = "tcp"
node_port = 6697
```
### Game Server with HTTP Admin Panel
```toml
name = "gameserver"
[services.game]
command = "./server"
[[services.game.ports]]
port = 3000
name = "admin"
type = "http"
[[services.game.ports]]
port = 27015
name = "game"
type = "udp"
node_port = 27015
[services.game.concurrency]
mode = "fixed"
num_instances = 1
```
### TCP Echo Server
```toml
name = "tcp-echo"
[services.echo]
command = "./tcp-echo"
[[services.echo.ports]]
port = 3000
name = "health"
type = "http"
[[services.echo.ports]]
port = 7000
name = "echo"
type = "tcp"
node_port = 7000
[services.echo.concurrency]
mode = "fixed"
num_instances = 1
```
## Next Steps
- [app.toml Reference](./app-toml.md#ports) — Complete field reference for port configuration
- [Services](./services.md) — Defining services, commands, images, and scaling
- [TLS Certificates](./tls.md) — How HTTPS works for HTTP services
- [Firewall Configuration](./firewall.md) — Host-level firewall rules and cloud provider setup
- [Application Scaling](./scaling.md) — How services scale up and down
---
## Troubleshooting
A step-by-step guide to diagnosing issues with your Miren applications and server.
## Quick health check
Start with `miren doctor` to verify your environment is set up correctly:
```miren
miren doctor
```
This checks your configuration, server connectivity, versions, and authentication, and names the command that fixes anything it finds wrong.
:::tip[Get a combined diagnosis]
Install the [Miren agent skills](./agent-skills.md) and ask your AI coding agent to
"check the health of this app" or "check the health of this cluster."
The `app-health` and `cluster-health` skills combine status, deployment history,
logs, and diagnostics into a prioritized report with suggested next steps.
:::
## App not starting
If your app is deployed but not responding:
**1. Check the app status**
```miren
miren app status -a myapp
```
This shows the current deployment state, version, configuration, and any error messages.
**2. Check the logs**
```miren
# Recent logs
miren logs -a myapp
# Live tail
miren logs -a myapp -f
# Filter for errors
miren logs -a myapp -g error
# Logs from a specific service
miren logs -a myapp --service web
```
**3. Check sandbox state**
```miren
miren sandbox list
```
Look for sandboxes that are stuck in `pending` or `not_ready`, or that have gone `dead`. Use `--all` to include dead sandboxes in the output.
## Deploy failed
**1. Find the failed deployment**
```miren
miren app history -a myapp
```
Failed deployments are marked with a red `✗`. Use `--detailed` for more info including error messages and git SHAs.
**2. Check build logs**
```miren
miren logs build -a myapp VERSION
```
Replace `VERSION` with the version from the deployment history. This shows the build output so you can see where things went wrong. See [Logs](./logs.md) for more on filtering and following logs.
## Server-level issues
If you suspect the Miren server itself is having problems:
**1. Check server logs on the host**
For systemd installations:
```bash
sudo journalctl -u miren -f
```
For container installations (Docker or Podman), use whichever runtime you
installed with:
```bash
docker logs -f miren # or: podman logs -f miren
```
:::note[Podman and restart-on-reboot]
Docker's daemon restarts the miren container automatically after a reboot.
Podman is daemonless, so `--restart always` only covers crashes while the
machine is up — it won't bring the container back after a reboot on its own.
On a systemd host, enable `podman-restart.service`
(`sudo systemctl enable --now podman-restart.service`, or the `--user` variant
plus `loginctl enable-linger` for a rootless install), or generate a dedicated
unit with `podman generate systemd` / a Quadlet.
:::
The restart policy is also what `miren cluster restart` and `miren cluster
upgrade` rely on for a container install: the server exits and the runtime
brings a new container up on the same data volume. `miren server container
install` sets `--restart always`; a container started some other way without
it will stay down after a restart, and a server that wasn't started through
the image's entrypoint reports `install_kind: unknown` and refuses restart and
upgrade rather than trying.
**2. Test connectivity**
```miren
miren debug connection
```
This tests RPC and HTTP connectivity to the server and reports the server version and auth status.
## The server restarted on its own
On a systemd install, the Miren server runs under a memory limit. If it ever
exceeds that limit, the kernel stops the server and systemd starts it again.
That is deliberate: a server that grows without bound would otherwise take the
whole machine down, and a machine you can still reach is worth more than a
server that never restarts.
Your apps keep running through this. They have their own cgroups — the kernel's
per-workload memory accounting — outside the server's, so the limit does not
apply to them and the restart doesn't stop them.
When it happens, the server says so the next time it starts:
```miren
miren logs system
```
Look for `previous miren server run was killed for exceeding its memory limit`.
It carries the memory the server reached, the limit it was held to, and how many
times systemd has restarted it.
**Seeing the current limit**
```bash
systemctl show -p MemoryMax -p MemoryHigh miren
```
Miren sets this at install time to a quarter of the machine's memory, with a
floor of 2 GB, and refreshes it on each upgrade. The limit covers the server
process, containerd, and the per-container shims — not your apps or addon
databases.
Most of what it covers is cached file data, not the server itself. containerd
charges every image layer it reads to this group, so a busy machine can show
tens of gigabytes here while the server's own memory is a few hundred megabytes.
Cached data is given back automatically when the limit is approached, which is
why the limit is a share of the machine rather than a fixed size: it has to
leave room for that cache on a large host while still catching a runaway on a
small one.
**Raising it**
A single restart usually means a bug worth reporting. Repeated restarts on a
busy cluster can mean the limit is genuinely too low for your workload. Raise it
with a systemd override, which takes precedence over the value Miren manages and
survives upgrades:
```bash
sudo systemctl edit miren
```
Add:
```ini
[Service]
MemoryMax=24G
MemoryHigh=20G
```
Then `sudo systemctl daemon-reload && sudo systemctl restart miren`.
:::warning[Edit the override, not the unit file]
`systemctl edit` is the only place an override survives. A limit written
directly into `/etc/systemd/system/miren.service` is ignored in favour of the
one Miren manages, because systemd applies drop-in files after the unit file —
so a hand-written limit can be silently replaced by a lower one on the next
upgrade. If you set a limit before upgrading to this version, move it.
:::
:::warning[Keep MemoryHigh below MemoryMax]
Crossing `MemoryHigh` puts the server under reclaim pressure and slows it down
instead of stopping it. That buys time to notice and act, but it isn't a
recovery: the kernel can reclaim cached file data, not the server's live memory,
so a genuine leak still reaches `MemoryMax` and is stopped. Setting the two
equal, or leaving `MemoryHigh` unset, removes even the warning band and the
server goes straight from healthy to stopped.
:::
## Gathering a debug bundle
If you've worked through the steps above and need further help, collect a debug bundle to share:
```miren
sudo miren debug bundle
```
This creates a `miren-debug.tar.gz` archive containing system info, container state, process lists, and server logs.
:::warning[Review bundles before sharing]
Debug bundles collect diagnostic data that may include sensitive information:
- **Process command lines** — arguments passed to running processes may contain tokens or credentials
- **Application logs** — error messages and stack traces can include request data or internal details
Environment variable values are automatically redacted from container inspect output, but logs and process arguments are included as-is. Review the bundle contents and remove anything sensitive before sharing, especially in public channels like GitHub Issues.
:::
:::tip[Use sudo for a complete bundle]
Without sudo, the command still runs but produces a partial bundle. Root access is needed for:
- **Containerd socket** — the primary source of container state
- **System journal** — miren server logs via journalctl
- **Docker/Podman socket** — for Docker, unless your user is in the `docker` group (rootless Podman needs no group)
:::
### Bundle options
| Flag | Description | Default |
|------|-------------|---------|
| `-o, --output` | Output file path | `miren-debug.tar.gz` |
| `-s, --since` | Include logs since this time | `1 day ago` |
| `-d, --docker-container` | Server container name (Docker or Podman) | `miren` |
```miren
# Include logs from the last week
sudo miren debug bundle --since "7 days ago"
# Save to a specific path
sudo miren debug bundle -o /tmp/miren-debug.tar.gz
```
## Getting help
If you're stuck, share your debug bundle and what you've tried:
- **Discord** — [miren.dev/discord](https://miren.dev/discord) for community help and questions
- **GitHub Issues** — [File a bug report](https://github.com/mirendev/runtime/issues/new?template=bug_report.yml) and attach your debug bundle
- **Feature Requests** — [Miren Roadmap](https://github.com/mirendev/roadmap/issues) for ideas and suggestions
Remember to review debug bundles for sensitive data before attaching them to public issues.
---
## Web Application Firewall (WAF)
Miren includes a built-in WAF that filters malicious HTTP requests before they reach your app. It uses the [OWASP Core Rule Set](https://coreruleset.org/) (CRS) to detect common attacks like SQL injection, cross-site scripting (XSS), and path traversal.
WAF is configured per route. Enable it and all requests to that route are inspected — malicious requests get a `403 Forbidden` response, clean requests pass through normally. No changes to your app are needed.
:::note[What this doesn't cover]
The WAF inspects request content for attack payloads (SQL injection, XSS, command injection, path traversal). It does **not** rate-limit, fingerprint bots, or block reconnaissance scans (e.g., probes for `/wp-admin/` or `/xmlrpc.php` on non-WordPress sites). For that kind of filtering, use an upstream proxy like Cloudflare in front of your route.
:::
## Minimum working example
```miren
miren route waf myapp.example.com
```
That's the entire setup — WAF is enabled at paranoia level 1 (the default), which catches well-known attack patterns with minimal false positives. Malicious requests to the route now get a `403 Forbidden`; clean requests pass through unchanged.
To use a higher paranoia level:
```miren
miren route waf myapp.example.com --level 2
```
## Disabling WAF
```miren
miren route waf myapp.example.com --disable
```
## Paranoia Levels
The paranoia level controls how aggressively the WAF inspects requests. Higher levels catch more attacks but may also flag legitimate requests as malicious (false positives).
| Level | Description | Use case |
|-------|-------------|----------|
| **1** | Baseline — catches obvious attacks (SQL injection, XSS, etc.) with very few false positives | Most apps (recommended default) |
| **2** | Elevated — additional rules for less common attack patterns | Apps handling sensitive data |
| **3** | High — stricter matching, may flag unusual but legitimate requests | High-security environments |
| **4** | Maximum — strictest rule set, highest false positive rate | Specialized security requirements |
Start with level 1. If you need tighter security, increase the level and monitor for false positives.
## What Gets Blocked
The OWASP CRS protects against the most common web attack categories:
- **SQL injection** — `?id=1 OR 1=1--`
- **Cross-site scripting (XSS)** — `?q=`
- **Path traversal** — `/../../etc/passwd`
- **Remote code execution** — shell command injection in parameters
- **Protocol violations** — malformed HTTP requests
- **Request smuggling** — ambiguous content-length/transfer-encoding
When a request is blocked, the client receives a `403 Forbidden` response. The original request never reaches your app.
## Checking WAF Status
WAF status appears in both `route show` and `route list`:
```miren
miren route show myapp.example.com
```
The output includes a `WAF Level` field when WAF is enabled. `route list` shows a `WAF` column with the level number or `-` when disabled.
## Default Route
WAF works with the default route too:
```miren
miren route waf --default --level 1
miren route waf --default --disable
```
## JSON Output
Both `route waf` and `route show` support `--format json` for scripting:
```miren
miren route waf myapp.example.com --format json
miren route show myapp.example.com --format json
```
The JSON output includes a `waf_level` field (integer, 0 when disabled).
## How It Works
WAF inspection runs in the HTTP ingress layer, before any other middleware (including OIDC authentication). The processing order for an incoming request is:
1. **WAF** — inspect the request against OWASP CRS rules
2. **OIDC** — authenticate the user (if route protection is configured)
3. **Proxy** — forward the request to the app sandbox
Miren uses [Coraza](https://coraza.io/), an open-source WAF engine compatible with ModSecurity rules, with the full OWASP Core Rule Set embedded. WAF engines are created per paranoia level and cached — there's no per-request initialization overhead.
:::warning[Request body size limit]
Request bodies up to 10 MB are inspected. Requests with bodies exceeding this limit are rejected with HTTP 413.
:::
## See Also
- [CLI: `miren route waf`](./command/route-waf.md) — enable or disable WAF on a route
- [Traffic Routing](./traffic-routing.md) — how routes work
- [Protecting Routes](./route-protect.md) — OIDC authentication for routes
---
## Workload Identity
Every sandbox running on Miren automatically receives a signed OIDC identity token. Your code can present this token to external services — AWS, GCP, Azure, or your own APIs — to prove *which workload it is* without you storing any long-lived cloud credentials in the app.
It works the same way GitHub Actions' OIDC tokens do: the platform (here, your Miren cluster) acts as an OpenID Connect issuer, signs a short-lived JWT describing the workload, and publishes the public keys so anyone can verify it.
:::info[Workload identity vs. CI/CD OIDC]
These are two sides of the same OIDC machinery, pointed in opposite directions:
- **Workload Identity** (this page) — your cluster issues tokens **for** the sandboxes running on it, so your *running app* can call out to AWS, GCP, etc.
- **[CI/CD Deployment](./ci-deploy.md)** — your cluster *verifies* tokens issued **by** GitHub/GitLab, so a *pipeline* can deploy to Miren without stored secrets.
Both rely on the cluster's OIDC infrastructure, but the token flows in different directions.
:::
:::tip[Calling the Miren API from a sandbox]
The same token can authenticate to your cluster's own API — deploy, read logs, open a shell — scoped by a role you choose per app. See [In-Cluster API Access](./in-cluster-api.md).
:::
## Minimum working example
Every sandbox already has a token — no configuration needed. Read it from the mounted file:
```bash
TOKEN=$(cat "$MIREN_IDENTITY_TOKEN_PATH")
```
Or request one scoped to a specific audience from the token server:
```bash
TOKEN=$(curl -s -H "Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET" \
"$MIREN_IDENTITY_TOKEN_URL?audience=sts.amazonaws.com&ttl=900" | jq -r .value)
```
Present the JWT to any service configured to trust your cluster's issuer (`$MIREN_OIDC_ISSUER_URL`) — see [AWS via STS Federation](#aws-via-sts-federation) for the end-to-end flow.
## How It Works
1. **Your cluster is an OIDC issuer.** It owns a signing key and publishes a standard discovery document at `/.well-known/openid-configuration` and its public keys (JWKS) at `/.well-known/miren/jwks`.
2. **Every sandbox gets a token.** When a sandbox starts, Miren mints a JWT describing it (organization, cluster, app, sandbox ID), writes it into the container, and refreshes it on a background loop.
3. **Your app presents the token** to an external service that's been configured to trust your cluster as an identity provider.
4. **The external service verifies it** by fetching your cluster's discovery document and JWKS, checking the signature, and matching the token's claims against its own access rules — then grants short-lived, scoped credentials.
No secret is shared in advance. The external system trusts your cluster's *issuer URL* and verifies signatures against its published keys.
## Two Ways to Get a Token
A sandbox can obtain its identity token two ways:
1. **Read the file** at `/var/run/miren/identity-token` — the simplest path, always present, refreshed for you.
2. **Call the token server** when you need a token with a specific audience or a shorter lifetime than the standard refresh provides.
Both are wired up through environment variables that Miren injects into every sandbox:
| Environment variable | Value | Use |
| --- | --- | --- |
| `MIREN_IDENTITY_TOKEN_PATH` | `/var/run/miren/identity-token` | Path to the auto-refreshed token file |
| `MIREN_OIDC_ISSUER_URL` | e.g. `https://cluster.example.com` | The cluster's issuer; matches the token's `iss` claim |
| `MIREN_IDENTITY_TOKEN_URL` | e.g. `http://10.x.x.1:7123/v1/token` | On-demand token endpoint |
| `MIREN_IDENTITY_TOKEN_SECRET` | a 32-byte hex secret | Bearer credential for the token endpoint |
Prefer these environment variables over hardcoding paths or URLs — the token-server address in particular is internal and not a stable value.
## The Identity Token File
The simplest way to use workload identity is to read the file:
```bash
$ cat "$MIREN_IDENTITY_TOKEN_PATH"
eyJhbGciOiJSUzI1NiIsImtpZCI6...
```
- It's a standard signed JWT, mounted **read-only**.
- Miren refreshes it **in place** on a background loop (roughly every 45 minutes), well before it expires.
Because the file is refreshed in place, **read it fresh each time you need it** rather than caching the contents at startup. If your workload runs for a long time and reads the token only once, you may end up holding a token that's about to expire. When in doubt — or when you need a custom audience or a shorter TTL — use the token server instead.
## The Token Server
For tokens with a specific audience or a custom lifetime, call the on-demand token server. It's a small HTTP endpoint reachable from inside the sandbox at `MIREN_IDENTITY_TOKEN_URL`.
```bash
$ curl -H "Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET" \
"$MIREN_IDENTITY_TOKEN_URL?audience=sts.amazonaws.com&ttl=900"
```
Response:
```json
{ "value": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." }
```
**Request**
- Method: `GET` only (other methods return `405`).
- Auth: `Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET`. The secret is unique per sandbox.
- Query parameters (both optional):
- `audience` — the intended recipient(s) of the token. Repeat the parameter for multiple audiences. Defaults to `miren` if omitted.
- `ttl` — token lifetime in seconds. Default `3600` (1 hour), minimum `60`, maximum `86400` (24 hours).
**Errors**
| Status | Meaning |
| --- | --- |
| `400` | Bad request (e.g. `ttl` out of range or not a number) |
| `401` | Missing or malformed `Authorization` header |
| `403` | Bearer token doesn't match the requesting sandbox |
| `405` | Method other than `GET` |
| `500` | Token issuance failed |
## What's in a Token
Each token is a JWT carrying the standard registered claims plus a few Miren-specific ones describing the workload:
| Claim | Description |
| --- | --- |
| `iss` | Issuer — your cluster's OIDC URL (same as `MIREN_OIDC_ISSUER_URL`) |
| `sub` | Subject — a structured identity string (see below) |
| `aud` | Audience — who the token is for (defaults to `miren`, or what you requested) |
| `exp`, `iat`, `nbf` | Expiry, issued-at, and not-before timestamps |
| `jti` | Unique token ID |
| `organization_id` | Your organization (for cloud-registered clusters) |
| `cluster_id` | The cluster that issued the token |
| `app` | The application name |
| `sandbox_id` | The sandbox instance |
| `identity_type` | The kind of principal the token represents (always `sandbox` for the tokens your app receives) |
The `sub` (subject) encodes the workload's identity as a path-like string, omitting any empty parts:
```
org::app::sandbox:
```
:::warning[Subject delimiter]
Each label and value is one colon-delimited segment. Miren refuses to mint a
token when any segment contains `:`, rather than emit a subject that could be
decoded as a different identity.
:::
A decoded token payload looks like:
```json
{
"iss": "https://cluster-aabbcc.miren.systems",
"sub": "org:org-demo-xyz:app:demo:sandbox:sandbox/demo-web-xxyyzz",
"aud": "sts.amazonaws.com",
"exp": 1718053200,
"iat": 1718049600,
"nbf": 1718049600,
"jti": "a1b2c3d4-...",
"organization_id": "org-demo-xyz",
"cluster_id": "cluster-aabbcc",
"app": "demo",
"sandbox_id": "sandbox/demo-web-xxyyzz",
"identity_type": "sandbox"
}
```
:::note[System workload tokens]
Tokens issued to your sandboxes always carry `identity_type: "sandbox"`. Miren
issues tokens to its own system workloads as well, with a different value and a
different subject shape, but those are never handed to an app and aren't
something you request.
:::
External systems use these claims to decide what a token is allowed to do — for example, an AWS role trust policy can require a specific `sub` or `aud` before handing back credentials.
## Use Cases
### AWS via STS Federation
The canonical use case: let a sandbox assume an AWS IAM role and receive temporary credentials, with no `AWS_ACCESS_KEY_ID` stored anywhere.
1. **In AWS**, register your cluster as an OIDC identity provider (its issuer URL is `MIREN_OIDC_ISSUER_URL`) and create an IAM role whose trust policy federates that provider. Scope the trust policy with a condition on the token's `sub` or `aud` so only the workloads you intend can assume the role.
2. **In your sandbox**, request a token scoped to STS and exchange it:
```bash
TOKEN=$(curl -s --get "$MIREN_IDENTITY_TOKEN_URL" \
-H "Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET" \
--data-urlencode "audience=sts.amazonaws.com" | jq -r .value)
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/miren-web \
--role-session-name web \
--web-identity-token "$TOKEN"
```
AWS verifies the token against your cluster's published keys, checks the trust policy, and returns short-lived credentials.
### GCP and Azure
Both Google Cloud and Azure support OIDC-based **workload identity federation**. Configure a workload identity pool / federated credential that trusts your cluster's issuer URL and matches on the token's subject or audience, then exchange the Miren token for cloud credentials using each provider's federation flow. The mechanics differ per provider, but the trust relationship is the same: they verify the token against your cluster's JWKS.
For the provider-specific setup, see:
- [GCP Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation) — create a workload identity pool with an OIDC provider pointing at your cluster's issuer URL.
- [Azure workload identity federation](https://learn.microsoft.com/entra/workload-id/workload-identity-federation) — add a federated credential to an app registration or managed identity, using your cluster as the issuer.
### Internal Service-to-Service Auth
Your own services can accept Miren identity tokens directly. A service verifies the token against the cluster's JWKS (see [Verifying Tokens](#verifying-tokens) below) and then authorizes based on the claims — for example, only accepting requests from a particular `app`.
### Per-Workload Access Control
Because each token carries `organization_id`, `cluster_id`, `app`, and `sandbox_id`, downstream systems can make fine-grained decisions: grant one app access to one bucket, gate a multi-tenant API on `organization_id`, or trace a request back to the exact sandbox that made it.
## Verifying Tokens
Any system that wants to trust Miren-issued tokens follows the standard OIDC verification flow:
1. **Discover** — fetch the discovery document at:
```
/.well-known/openid-configuration
```
It advertises the issuer, the `jwks_uri`, and supported signing algorithms.
2. **Fetch keys** — retrieve the JSON Web Key Set at:
```
/.well-known/miren/jwks
```
Tokens are signed with **RS256** by default, which every standard OIDC verifier supports.
3. **Verify** — check the JWT signature against the JWKS, confirm the token isn't expired, and **pin the issuer**: the token's `iss` claim must exactly match the issuer URL you trust.
The issuer URL is whichever anchor the cluster is using — see below. Either way it's the value exposed to sandboxes as `MIREN_OIDC_ISSUER_URL`.
## Where the Issuer Lives {#anchor}
Two things have to be true for federation to work: something has to *sign* the tokens, and something has to *serve* the public keys an outside verifier fetches to check them. Miren splits those deliberately.
**The signing key never leaves your cluster.** It's generated on the cluster, written to `/server/workload-identity.key`, and is not uploaded anywhere — not to Miren Cloud, not to us. That's the property that matters: a compromise of Miren Cloud yields public keys and no ability to mint an identity for your workloads.
Who serves the keys follows from registration (see [server config](./server-config.md#workload-identity)):
| Anchor | `iss` claim | Discovery served by |
| --- | --- | --- |
| `cluster` | your cluster's hostname, e.g. `https://cluster-abc.miren.systems` | your cluster's ingress |
| `cloud` | `https://api.miren.cloud/identity/` | Miren Cloud, from keys your cluster published |
**Clusters registered with Miren Cloud anchor at cloud by default.** A cluster registered before this default existed, or one running without cloud, stays on the cluster anchor — an upgrade never moves an anchor on its own, because moving it changes `iss`. Anchoring at cloud costs nothing in trust and buys two things:
- **Federation works for clusters that aren't reachable from the internet.** AWS, GCP, and Azure all fetch your JWKS over the public internet. A cluster behind carrier NAT can't serve that, and previously couldn't federate at all.
- **Federation survives cluster downtime.** Discovery stays up while the cluster reboots or its certificate renews.
When anchored at cloud, the cluster publishes the public half of its key set at startup and again whenever the set changes. Rotation propagates the same way, with no per-verifier reconfiguration.
A cluster on the cluster anchor publishes nothing — Miren Cloud holds no key material for it, and its `/identity/...` discovery endpoint returns 404. Publication follows the anchor rather than registration, so cloud only ever serves a discovery document for an issuer that some token actually carries.
:::warning[Moving the anchor is a cutover for external verifiers]
The `iss` claim gets pinned in every external trust configuration you set up — an AWS IAM OIDC provider, a GCP workload identity pool. Moving the anchor on a cluster that's already federating invalidates all of them until each is repointed at the new issuer. Inside the cluster the move is handled for you; outside it, it is a coordinated change.
:::
### Moving the anchor
```bash
miren server identity-anchor cloud # let Miren Cloud serve discovery
miren server identity-anchor cluster # go back to serving it yourself
```
Run it on the cluster host. It records the choice and restarts the server, which is what puts the new anchor into effect.
A cluster registered before anchors existed has none recorded yet, so the command will say so. Miren Cloud repeats the anchor on every status report and the cluster writes it down the first time it sees it, so waiting one report cycle (five minutes) after upgrading is enough — no re-registration.
Inside the cluster, the move is seamless. The server remembers the anchor it was minting under, recognizes the change on the next boot, and **keeps accepting the old issuer until every token that could carry it has expired** — 25 hours, comfortably past the 24-hour maximum token lifetime. Without that window the move would take out the cluster's own services, which verify these tokens too. During it, the old hostname also keeps serving discovery, so an external verifier you haven't repointed yet can still check tokens minted before the move.
Mounted token files are rewritten during that restart, so a sandbox gets its new token without waiting for the refresh loop.
:::warning[Apps may need a restart]
Rewriting the token file only helps a workload that **re-reads** `MIREN_IDENTITY_TOKEN_PATH`. An app that read its token once at startup and kept it in memory goes on presenting the old issuer until it restarts or its cached token expires — and once the overlap window closes, that token stops verifying.
`MIREN_OIDC_ISSUER_URL` is also injected as an environment variable at sandbox start, so a running container keeps reporting the old issuer until it restarts, even though the token file next to it has moved on.
If your apps cache their token, plan a rolling restart after moving the anchor. Re-reading the file each time you need a token — which the [token file](#the-identity-token-file) section already recommends — avoids the problem entirely.
:::
Moving the anchor twice within the overlap window keeps only the most recent previous issuer; tokens still carrying the earliest one stop verifying. The server warns when this happens.
## Sharp Edges & Limitations
### Federation needs a hostname; identity itself doesn't
Workload identity turns on automatically — there's no per-app setting to enable it — and every cluster issues tokens, including one installed with `--without-cloud` and no TLS name. Your sandboxes always get the `MIREN_IDENTITY_*` variables.
What such a cluster lacks is an issuer an outside party can reach. It anchors its tokens at `https://cluster.local`, which nothing outside the cluster can fetch a discovery document from, so **external federation won't work**: AWS, GCP, and Azure all need to reach your issuer URL to fetch its keys.
Two ways to fix that. Give the cluster a public DNS record that routes to its ingress, and put that name in `--dns-names` so the certificate covers it; the flag alone only adds a name to the certificate, it does not publish a record or make the cluster reachable. Or register with Miren Cloud, which anchors the cluster there and is the option that works when it isn't reachable from the internet at all.
:::note[Why tokens exist either way]
Miren's own services authenticate to each other with these tokens — the cluster-local registry and the telemetry that distributed runners ship both verify them against the signing key in-process, which needs no DNS and no publicly trusted certificate. Tying identity to being externally addressable would leave those services with nothing but the network to trust.
:::
### The file refreshes on a fixed loop — read it fresh
The token file is refreshed roughly every 45 minutes, in place. This interval is an internal detail, not a tunable. The practical consequence: **don't cache the file's contents** for the life of a long-running process. Re-read `MIREN_IDENTITY_TOKEN_PATH` each time you need a token, or use the token server when you need control over lifetime or audience.
### Token-server address is internal
`MIREN_IDENTITY_TOKEN_URL` points at an internal router address and a fixed port (7123). Always use the injected environment variable rather than hardcoding either — they are implementation details and may change.
### Distributed runners issue tokens via the coordinator
In a cluster with [distributed runners](./distributed-runners.md), only the coordinator holds the signing key. On a distributed runner, token issuance is proxied back to the coordinator over RPC. Two consequences worth knowing:
- There's a small amount of extra latency, and issuance depends on the coordinator being reachable.
- A runner that can't reach the coordinator's issuer at startup disables token issuance for its sandboxes, so they won't get the `MIREN_IDENTITY_*` variables. The coordinator always has an issuer, so in practice this means a connectivity problem rather than a configuration one.
### Restarts and the token-server secret
The token server authenticates on-demand requests using a per-sandbox secret (`MIREN_IDENTITY_TOKEN_SECRET`) held in an in-memory registry. To survive a controller or server restart, each secret is also persisted host-side and re-registered for still-running sandboxes during boot reconciliation. This is handled for you; it's documented here so the behavior isn't surprising if you're inspecting the host filesystem.
### Key rotation is operator-driven
The cluster's signing key lives alongside the server data. Rotation supports an overlap window so in-flight tokens keep verifying:
- The current key can be demoted to a `.prev` file, which is still published in the JWKS for verification but no longer used to sign.
- Additional live keys can be placed in a `workload-identity.d/` directory; all live keys are published, but only the primary signs.
- Rotation is **not** automatic — an operator must move keys deliberately, and clear a stale `.prev` before rotating again.
A cluster anchored at cloud publishes its new key set on the next status cycle after a rotation, so verifiers pick it up without you touching their trust configuration. A cluster serving its own discovery publishes it the moment it restarts with the new key.