# 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. ## 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). ## 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 | 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 (`miren-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 (`miren-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 (`miren-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 (`miren-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 (`miren-memcache`) | Variable | Description | Example | |----------|-------------|---------| | `MEMCACHE_URL` | Connection URL | `memcache://host:11211` | | `MEMCACHE_HOST` | Server hostname | `10.10.0.196` | | `MEMCACHE_PORT` | Server port | `11211` | ### Inspecting injected variables You can verify the variables are set on your app: ```miren miren env list -a myapp ``` ## 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). Your app won't start until addon provisioning completes — Miren holds off launching your app's processes until all addons reach active status. ### Checking Status List addons attached to your app: ```miren miren addon list -a myapp ``` ### Removing an Addon Remove an addon and delete its data: ```miren miren addon destroy miren-postgresql -a myapp ``` :::warning 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 :::info Labs Feature The admin interface is a [labs feature](/labs) and is disabled by default. Enable it with `--labs adminapi` or `MIREN_LABS=adminapi` when starting the server. ::: 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. ## 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. **You must validate this token on every request:** ```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 strings.TrimPrefix(auth, "Bearer ") != token { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } next.ServeHTTP(w, r) }) } ``` The admin endpoint also receives an `X-Miren-Access` header: - `internal` — Request is from Miren's admin system (trusted) - `public` — Request is from an external client (Miren strips any client-provided value) You can use this header for additional access control if your endpoint is accidentally exposed to the internet. ### 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 } ``` 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. 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" "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 strings.TrimPrefix(auth, "Bearer ") != token { 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 ### Python Example ```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 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 }) ``` ## Calling Admin Methods Once your app exposes the admin interface, use the CLI to call methods: ```miren # List available methods miren admin --list # Call a method miren admin get-user user_id=user-1 # Call with complex parameters miren admin update-config settings='{"debug": true}' # Output as JSON (for scripting) miren admin get-stats --json | jq '.total' ``` See [Admin Commands](/command/admin) for full CLI documentation. ## Next Steps - [Admin Commands](/command/admin) — CLI reference for `miren admin` - [Services](/services) — Configure your app's web service - [Getting Started](/getting-started) — 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 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 deploy with zero configuration—Miren detects your language, builds your image, and runs it with sensible defaults. When you need to customize, you add a `.miren/app.toml` file. ## 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 [Supported Languages](/languages) - **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 - **Customize builds** — specify a Dockerfile, language version, or extra build steps - **Configure addons** — managed databases and other backing services (see [Addons](/addons)) ## 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 [Supported Languages](/languages) 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" ``` See [Services](/services) 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) 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) 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 | | `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. ### 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) 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). --- ## 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). ## File Structure ```toml name = "myapp" post_import = "make db-migrate" 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 # 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 | | `post_import` | string | Command to run after importing a new version (e.g. database migrations) | — | | `include` | string[] | Extra files or directories to include in the build context | — | | `concurrency` | int | **Legacy.** Global concurrency target. Use `[services..concurrency]` instead. | — | ## `[[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" ``` | Field | Type | Description | Default | |-------|------|-------------|---------| | `key` | string | Variable name. **Required.** | — | | `value` | string | Variable value | `""` | | `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. ::: ## `[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 | ## `[services.]` — Service Configuration {#services} Each named section under `services` defines a process in your app. See [Services](/services) for usage patterns. ```toml [services.web] command = "node server.js" port = 3000 port_name = "http" port_type = "http" [services.postgres] image = "postgres:16" ``` | Field | Type | Description | Default | |-------|------|-------------|---------| | `command` | string | Command to run | Image's default entrypoint | | `port` | int | Port the service listens on (single-port shorthand) | `3000` (web only) | | `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 instead of the app's built image | App's built image | | `env` | [[env]](#env) | Service-specific environment variables (same schema as global `[[env]]`) | — | | `concurrency` | [concurrency](#concurrency) | Scaling configuration | See defaults below | | `disks` | [[disk]](#disks) | Persistent disk attachments | — | :::note You cannot mix the single-port fields (`port`, `port_name`, `port_type`) with the `ports` array on the same service. ::: ### `[services..concurrency]` — Scaling {#concurrency} Controls how many instances of a service run. See [Application Scaling](/scaling) 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) 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) for details on local storage vs. Miren Disks. ```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) | — | :::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; services **must** use `mode = "fixed"` and `num_instances = 1`. - `lease_timeout` must be a valid Go duration (e.g. `"30s"`, `"2m"`). ::: ## `[addons.]` — Addons {#addons} Configures managed backing services. The `` is the addon identifier (e.g. `miren-postgresql`). See [Addons](/addons) 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 | | `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 *main* --- ## 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 `