# 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 `