Platform
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 appear in a From your registry picker. -
Port: what your container listens on. Lunary sets it as
PORTin the container environment, so you do not type it twice. -
Health check path (optional): with none set, the image's own
HEALTHCHECKapplies. -
Database (optional): PostgreSQL 18, 17 or 16, run beside the app. Lunary generates the password and sets
DATABASE_URLin 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
.envfile 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:
{
"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:tagorname@sha256:…). Docker Hub is assumed when no registry is given. -
port is what the container listens on, and is set as
PORTin 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 (422missing_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_SNAKEname with a recipe:base64:<bytes>(URL-safe, no padding) orhex:<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 inenvorsecrets, 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
portreturning 2xx when the app is ready; it drives the health statuses in Services & instances. The check runs inside your container withwgetorcurl, so the image needs a shell and one of the two. -
limits (optional) are hard caps on the container, each independent:
cpus(vCPUs) andmemory_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:
engineispostgres,versionis18(the default),17or16. Lunary generates the password and setsDATABASE_URLin the app's environment, so neither you nor an agent handles the credential;DATABASE_URL,POSTGRES_PASSWORDand the volume namedbare reserved while a database is declared. The database is dumped daily by thebackupplug and restorable withrestore(Backups & 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.
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:
{
"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,
nullremoves 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 (422missing_secrets). A generated secret, or the database password, is neither replaced nor removed here (422invalid). -
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:
{
"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
pathsets it,nullremoves it so the image's ownHEALTHCHECKapplies. -
limits replaces the caps whole:
cpusand/ormemory_mib;nullor{}lifts them. A change that raises what the instance costs is priced against your plan on the dimensions it raises (422over_budget, naming them indetails.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
invalidnaming it otherwise), new ones join it. A new volume is an emptystorage/<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. Any other private image needs a pull credential for its registry host, stored once per organization under Access → Registries:
-
POST /api/v1/registrieswithhost,username, andtoken(write_instances). One credential per host; delete it to rotate. -
GET /api/v1/registrieslists each credential'sid,host,username, andcreated_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 ofaws 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 for the full rules and failure codes, and Registry for a CI job that builds, pushes and rolls a release in one step.
Reading with an agent? This page as markdown, or the whole docs as llms.txt.