# Custom apps

> Run your own container image as an instance from the dashboard or the API, with the same exposure, health, logs and backups as a catalog app.

Any single-container image can run as an instance, with a PostgreSQL database beside it when the app needs one. You describe the app (image, port, environment, secrets, volumes, a health check, resource limits, the database) and Lunary renders the compose file server-side. A compose file is never accepted: the platform owns networking, publish addresses, restart policy and volume placement, which is what keeps the guarantees below structural rather than advisory.

Nothing here is operator-only. The dashboard and the API take the same spec and produce the same instance. The one exception is `generated_secrets`, which only the API and MCP take: a person at the dashboard types a value, an agent should never hold one.

## Deploying from the dashboard

**New deployment** offers the catalog apps as tiles and **Custom app** as a branch below them. The custom branch asks for:

- **Name**: lowercase, hyphenated; typed input is normalised as you go. Your instance's address is shown live under the field, so the name and the hostname are never decided separately.
- **Image**: `registry.lunary.cloud/acme/app:1.4.2`, or any registry. A tag or digest is required; Docker Hub is assumed when no registry is given. Images you have pushed to the [Lunary registry](/platform/registry) appear in a **From your registry** picker.
- **Port**: what your container listens on. Lunary sets it as `PORT` in the container environment, so you do not type it twice.
- **Health check path** (optional): with none set, the image's own `HEALTHCHECK` applies.
- **Database** (optional): PostgreSQL 18, 17 or 16, run beside the app. Lunary generates the password and sets `DATABASE_URL` in the container; the database is dumped daily.
- **Resource limits** (optional): small / medium / large presets, or CPU and memory by hand, or none.
- **Env vars**: one editor, a row per variable, each row marked plain or secret. Secret rows are stored encrypted and never shown again. A `.env` file can be pasted in whole.
- **Volumes**: `name:/mount/path`, one per line.

The last step shows the rendered `docker-compose.yml` read-only, exactly as the VM will receive it, with secrets as references rather than values.

## The typed spec

Over the API, provision with `catalog_slug: "custom"` and describe the app in `custom`:

```json
{
  "catalog_slug": "custom",
  "slug": "app",
  "custom": {
    "image": "registry.lunary.cloud/acme/app:1.4.2",
    "port": 4000,
    "env": { "PHX_HOST": "app.acme.lunary.cloud" },
    "secrets": ["SENDGRID_API_KEY"],
    "generated_secrets": { "SECRET_KEY_BASE": "base64:64" },
    "volumes": [{ "name": "data", "mount": "/var/lib/app" }],
    "healthcheck": { "path": "/healthz" },
    "limits": { "cpus": 0.5, "memory_mib": 1024 },
    "database": { "engine": "postgres", "version": "18" }
  },
  "secrets": { "SENDGRID_API_KEY": "…" }
}
```

- **image** must carry a tag or digest (`name:tag` or `name@sha256:…`). Docker Hub is assumed when no registry is given.
- **port** is what the container listens on, and is set as `PORT` in its environment; TLS, the public URL, the address and custom hostnames work as for catalog apps.
- **env** is non-secret. Names listed in **secrets** take their values from the request's top-level `secrets`, are stored encrypted, and are never returned. Use it for a value that exists elsewhere, such as a third-party API key. Every name listed needs its value in the same request (422 `missing_secrets`), unless the name re-creates an instance over kept data, whose stored secrets are adopted instead; for a secret a person will add after deploy under **Settings → Environment**, leave the name out here too.
- **generated_secrets** names the secrets the app needs but nobody has to know, a session or signing key, each an `UPPER_SNAKE` name with a recipe: `base64:<bytes>` (URL-safe, no padding) or `hex:<bytes>`, 16 to 256 bytes. Lunary generates each value at provision, stores it encrypted, sets it in the container and never returns it, so neither you nor an agent deploying over the API holds it. A name cannot also be in `env` or `secrets`, and a generated value is neither replaced nor removed by a later change.
- **volumes** are named, backed up, and restorable. Host paths are not settable.
- **healthcheck** (optional) is an HTTP path on `port` returning 2xx when the app is ready; it drives the health statuses in [Services & instances](/platform/services-and-instances). The check runs inside your container with `wget` or `curl`, so the image needs a shell and one of the two.
- **limits** (optional) are hard caps on the container, each independent: `cpus` (vCPUs) and `memory_mib`. With none set the app shares the VM like a catalog app. Presets: small (0.25 vCPU / 512 MiB), medium (0.5 / 1 GiB), large (1 / 2 GiB). A limit you set is what your plan is charged; with none, 0.25 vCPU / 512 MiB / 2 GiB disk is budgeted.
- **database** (optional) is a PostgreSQL database run as a second service in the same stack: `engine` is `postgres`, `version` is `18` (the default), `17` or `16`. Lunary generates the password and sets `DATABASE_URL` in the app's environment, so neither you nor an agent handles the credential; `DATABASE_URL`, `POSTGRES_PASSWORD` and the volume name `db` are reserved while a database is declared. The database is dumped daily by the `backup` plug and restorable with `restore` ([Backups & restore](/platform/backups-and-restore)), and budgeted on top of the app at 0.25 vCPU / 256 MiB / 3 GiB.

MCP: `create_instance` takes the same `custom` object. See [MCP server](/api/mcp).

## How a custom app runs

Every custom app runs with the same posture, none of it settable: `restart: always`, `init: true` (tini as PID 1), `no-new-privileges`, every capability dropped except `NET_BIND_SERVICE`, a limit of 512 PIDs, the port published on loopback only, and logs in the host journal.

Build the image to run as a non-root user. An entrypoint that has to `chown` or switch users at start fails under this posture. An instance provisioned before the posture existed picks it up when its compose file is next rendered, which an image update does; a plain redeploy replays the stored file.

A declared database runs as the catalog's databases do, not under the app's posture: the official `postgres` image starts as root, takes ownership of its data directory under `storage/db` and drops to the `postgres` user. The app waits for it to answer before starting. Its image is pinned by digest and kept across image and configuration changes to the app.

## Changing env and secrets

A custom app's environment and secrets change with the same `PATCH /api/v1/instances/{slug}` that moves its image, in any combination, and one redeploy follows:

```json
{
  "env": { "LOG_LEVEL": "debug", "PHX_HOST": "app.acme.lunary.cloud" },
  "secrets": { "SENDGRID_API_KEY": "…", "OLD_TOKEN": null }
}
```

- **env** replaces the whole non-secret environment.
- **secrets** merges by name: a string sets or replaces the stored value, `null` removes the name and its value, a name not mentioned keeps what it has. Values are stored encrypted and never returned. Every declared secret must hold a value once the changes land (422 `missing_secrets`). A generated secret, or the database password, is neither replaced nor removed here (422 `invalid`).
- A catalog app's env and secrets belong to its preset (422 `not_custom`); only its image can change.

The dashboard's **Settings → Environment** card is the same change: plain rows are the env, secret rows show their names with the value blank, and Save re-renders and redeploys. Over MCP it is `update_instance_config`.

## Changing the health check, limits and volumes

The same request takes `healthcheck`, `limits` and `volumes`, alone or beside the fields above, and one redeploy follows:

```json
{
  "healthcheck": { "path": "/healthz" },
  "limits": { "cpus": 1, "memory_mib": 2048 },
  "volumes": [
    { "name": "data", "mount": "/var/lib/app" },
    { "name": "cache", "mount": "/var/cache/app" }
  ]
}
```

- **healthcheck** replaces the check: an object with `path` sets it, `null` removes it so the image's own `HEALTHCHECK` applies.
- **limits** replaces the caps whole: `cpus` and/or `memory_mib`; `null` or `{}` lifts them. A change that raises what the instance costs is priced against your plan on the dimensions it raises (422 `over_budget`, naming them in `details.dimensions`); lowering a limit is never refused, even on a plan that has since tightened.
- **volumes** is the whole list, add-only: every declared volume stays with its name and mount (422 `invalid` naming it otherwise), new ones join it. A new volume is an empty `storage/<name>` on the VM from the next start, backed up with the others; an instance declaring its first volume gains the volume snapshot and its daily routine, as a provisioned one does. Removing a volume is data loss and needs its own flow.
- A field not sent keeps what it has, so each can change on its own.

The dashboard's **Settings → Configuration** card is the same change, checked as you type by the rules above; a preset fills the two limit fields the way New deployment does, and the declared volumes are listed above the lines that add more. Over MCP it is `update_instance_config`.

The port and the database are fixed at provision: a new port, or a database added or removed, is a new instance. Data kept by a retaining teardown is adopted only by a spec declaring the same database, since `storage/db` is that database's data directory (409 `retained_database_mismatch` otherwise).

## Private registries

An image on the Lunary registry, `registry.lunary.cloud/<org>/<name>`, needs no credential here: your VM pulls it with a short-lived grant the bastion mints per deploy; see [Registry](/platform/registry). Any other private image needs a pull credential for its registry host, stored once per organization under **Access → Registries**:

- `POST /api/v1/registries` with `host`, `username`, and `token` (`write_instances`). One credential per host; delete it to rotate.
- `GET /api/v1/registries` lists each credential's `id`, `host`, `username`, and `created_at`; the token is never returned (`read_instances`).
- `DELETE /api/v1/registries/{id}` removes it (`write_instances`). Running instances keep their pulled image; the next deploy or redeploy of an image that needs the credential fails until a new one is stored. A public image on that host still pulls anonymously.

The credential is encrypted at rest and released only to your own VM, for the pull and start of a deploy, then removed. The token is entered once and never shown again; rotating it is delete, then add. Static credentials are the only kind:

- GHCR: a personal access token (classic) with `read:packages`. GitHub rejects fine-grained tokens and GitHub App tokens for package pulls. For an organization, mint it from a bot account with read access to only the packages Lunary should pull.
- Docker Hub: an organization access token (read-only) or a personal access token with read scope.
- GitLab: a deploy token with `read_registry`.
- ECR: username `AWS`, token the output of `aws ecr get-login-password`. It expires after 12 hours and nothing refreshes it: a later pull fails until you delete and re-add. Not viable as a standing credential.

MCP: `list_registries`, `add_registry`, `delete_registry`.

## Releasing a new version

`PATCH /api/v1/instances/{slug}` with a new `image` pulls and redeploys; a custom instance can move to any repository, and its port, env, secrets, volumes and health check carry over. See [Updating the image](/platform/services-and-instances#updating-the-image) for the full rules and failure codes, and [Registry](/platform/registry) for a CI job that builds, pushes and rolls a release in one step.
