Browse sections

Container Applications: Deploy, Promote & Roll Back Long-Lived Services

Run any OCI image as a long-lived service: build it from a Dockerfile or pull it from a registry, health-check it, give it a preview URL, then promote it to production with instant rollback. HTTP services get a hostname; databases and other TCP services get a dialable endpoint.

A function is code the platform runs inside its own runtime image, one invocation at a time. An application is an arbitrary container the platform keeps running: no idle timeout, raw reverse proxy (WebSockets and SSE pass through), and HTTP and raw TCP — Postgres, Redis, SMTP, SSH. Manage applications on the Apps page, with inquir apps in the CLI, or through the REST API; the vocabulary is the same everywhere.

Applications, releases, previews

  • Application — the service definition: name, ports, health check, resources, env vars, outbound policy. Editing it changes the next release, never a running one.
  • Release — an immutable image digest plus a frozen copy of the runtime spec and env, running as one container. Its lifecycle is Queued → Starting → Health check → Healthy → Serving → Rollback reserve → Stopped, or Failed. A release that fails its health check never receives traffic.
  • Preview — every new release comes up on its own preview URL ({app}-{release}.preview.apps.…) so you can test it before anyone else sees it. Previews are reaped after 24 hours unless promoted.
  • Promote — points production traffic (the production hostname and every custom domain) at a release. Manual, and restricted to owners and admins.
  • Roll back — points production at the previous release again. The previous container is kept running for the drain window, so rollback is instant while that reserve lasts.

Deploy your first service

With the CLI installed and logged in, an HTTP service is three commands. inquir apps deploy with no flags packages the current directory (or --dir), builds it from its Dockerfile on the platform, and releases the resulting image; pass --image to release a prebuilt image instead. The command waits until the release is healthy and prints its preview URL.

quickstart
# 1. Create the application — an HTTP service listening on port 3000
inquir apps create web --port 3000

# 2. Deploy: builds the Dockerfile in the current directory, releases the image
#    and waits until the health check passes. The release gets a preview URL.
inquir apps deploy web

#    …or release a prebuilt image instead of building
inquir apps deploy web --image nginx:alpine

# 3. Promote: production traffic moves to this release (owner/admin login)
inquir apps promote <releaseId>

inquir apps status web            # endpoints, hostnames, recent releases
inquir apps logs web --tail 100   # stream container logs
inquir apps rollback web          # back to the previous production release

The dashboard offers the same flow: New application, then New release (image or source build) on the Releases tab, then Promote on the release row. Ids and names are interchangeable in the CLI, and --json is accepted everywhere.

Health checks

A new release is gated behind a startup health check (up to 120 s by default; the container is inspected every 2 s so a crash fails fast); services with an HTTP port are re-probed periodically while they run (intervalSeconds, default 30 s). Three probe types exist; the default is http when the application has an HTTP port and tcp otherwise.

ProbeWhat it proves
httpGET on the health path (default /) answers with a status below 500 — a server that 404s its root is listening; only connection failures and 5xx count as unhealthy.
tcpThe port accepts a connection and keeps it open briefly.
commandA command run inside the container exits 0.

Use command for databases. Docker accepts connections on a published port before the container is actually listening, so a tcp probe can pass while Postgres is still initialising. --health-command "pg_isready -h 127.0.0.1" (or redis-cli ping) asks the service itself. In the API this is runtime.healthcheck: { type, path | command, port?, intervalSeconds, timeoutSeconds }.

Preview URL vs production URL

An application with an HTTP port has one production hostname ({app}.apps.…, plus any custom domains) that always follows the promoted release, and each release has its own preview URL until it is promoted or stopped. TLS is terminated at the edge for both. A pure-TCP service has no URL at all — it has endpoints (host:port per public TCP port) shown on the Releases tab and by inquir apps status.

Promote, roll back, stop

Promotion is a deliberate, confirmed step — the UI and CLI tell you what will happen before it does:

  • If the application has production-only env vars, the container is recreated under the production profile (health-gated) before traffic moves, so a preview never carried production credentials.
  • The release's preview URL retires once it serves production; the production hostname and every verified custom domain start routing to it.
  • The previous production release keeps running for the drain window (5 min by default, drainGraceSeconds per release) as the instant-rollback reserve. In-flight requests and WebSockets on it finish on their existing sockets.

Roll back (inquir apps rollback <app>, POST /v1/apps/:id/rollback) flips production back to the previous release: instant (200) while its container is still in the reserve, otherwise it is restarted from its own frozen image first (202). Rolling back is itself a promotion, so the same drain window applies to the release you left.

Stop (inquir apps stop <release>, DELETE /v1/releases/:id) stops any release that is not serving production — previews and superseded releases alike; the routed release answers 409. Stopping is how you free a pinned host port or a slot in the live-release quota. Archive (inquir apps delete) stops every release and unbinds every hostname; the name is free again afterwards.

TCP ports

Ports other than the primary HTTP port are declared with --tcp name:port[:public][:hostPort] (or runtime.ports[] with protocol: tcp). A TCP port is published on a host port and dialled directly — no runtime adapter, no HTTP assumption — so a database or SSH server is a first-class service:

postgres
# A database: no HTTP port, one public TCP port pinned to host port 25432,
# health-checked by asking the service itself (not by a TCP connect)
inquir apps create db \
  --tcp postgres:5432:public:25432 \
  --health-command "pg_isready -h 127.0.0.1" \
  --set POSTGRES_PASSWORD=secret
inquir apps deploy db --image postgres:16-alpine

# inquir apps status db  ->  TCP · tcp.inquir.org:25432 (postgres)
psql "postgres://postgres:secret@tcp.inquir.org:25432/postgres"
  • public publishes the port beyond loopback and gives the release an endpoint such as tcp.inquir.org:25432. Without it the port binds to loopback on the host (useful for debugging only). A public TCP port is reachable by anyone who can reach the host — the platform terminates no TLS and checks no credentials on raw TCP, so the service must authenticate its own clients.
  • hostPort pins the published port so connection strings survive a restart; an unpinned port is reassigned every time the container is recreated. Pins must fall inside the platform's port range (20000–39999 by default; the create form shows the live range) and are a host-wide reservation: a second live release pinning the same port is refused with 409 until the holder is stopped.
  • HTTP ports are never published separately — they are served over the hostname (TLS, Host routing, release lifecycle), so public and hostPort are rejected on an HTTP port. An application may have no HTTP port at all.

Volumes and databases

A volume is named storage attached to an application: declare it with --volume name:/absolute/path[:ro] (or runtime.volumes[]) and the platform creates one managed Docker volume per application and mounts it there. The physical name is derived from the application, not the release, so the same data volume comes back on every redeploy, crash restart and rollback — and it outlives the application: archiving stops every container and leaves the volume on the host for an operator to remove.

  • Only a logical name and an absolute container path are accepted — never a host path, a Docker volume name or driver options — so one workspace cannot mount another's storage. A runtime may declare at most 16 volumes; /, /dev, /proc and /sys are refused.
  • Declaring a volume flips the deploy to recreate: the running container is stopped before the new one starts, so two writers never share the volume. Expect a short outage on every deploy — and no promote step to confirm, because a recreate release takes production itself as soon as it is healthy. deployStrategy: rolling brings the side-by-side deploy back, and is accepted only when every volume is read-only — a writable one is refused with 400.
  • A writable volume pins the application to a single replica (scaling.maxReplicas above 1 is refused), and automatic HTTP wake is not offered for an application with volumes at all — scale-to-zero needs an HTTP-only runtime, so a volume application runs always or manual.

That is what the database templates stand on: inquir apps create mydb --template postgres (or --template redis) creates the application with a persistent volume, a pinned public TCP port so the connection string survives every redeploy, a generated password in encrypted env, and its first release — one command from nothing to a running database.

inquir apps connection-url mydb prints the live URL, credentials included. To grant another application access without the password passing through a person, run inquir apps connect mydb web: the server composes the URL and writes it into web's environment as DATABASE_URL (REDIS_URL for redis), where it takes effect on web's next release. The dashboard deliberately shows volumes and their backups, never credentials.

Backups

A backup is a snapshot of one volume's contents in the platform's artifact store, restorable onto that same volume. Backups exist only where the deployment has an object store configured — GET /v1/apps/limits reports it as features.volumeBackups, and the Volumes tab hides the controls when it is off.

  • Automatic: every application with a volume and a running production release is snapshotted every 6 hours, keeping the newest 7 snapshots per volume.
  • On demand: inquir apps backups mydb create snapshots every volume the production release mounts and waits until the rows settle (--no-wait returns straight away); inquir apps backups mydb list shows them, newest first.
  • Crash-consistent: the container is paused for the length of the copy, so the archive is a point-in-time image rather than a walk over a data directory that keeps changing underneath it. The copy is read through the running container, so an application that is not running cannot be backed up.
  • Restore replaces the volume. inquir apps backups mydb restore <id> --yes stops the application, replaces the volume's contents with the snapshot and starts it again; everything written since that snapshot is gone. Restoring and deleting a restore point need an owner or admin, like every other action that moves production.

Custom domains

Besides its generated hostname, an application is reachable at any hostname you bind to it — from the Domains tab or the CLI. A new hostname starts unverified: it is not routed and no certificate is issued until you prove ownership with a TXT record _inquir-verify.<host> carrying the printed token, plus a CNAME to domains.inquir.org (or an A/ALIAS to the ingress). Then verify:

domain
# Bind the hostname — it starts unverified and prints the DNS records to publish
inquir apps domain shop shop.example.com
#   TXT   _inquir-verify.shop.example.com  ->  <token>
#   CNAME shop.example.com                 ->  domains.inquir.org

# Once DNS has propagated, prove ownership: the hostname becomes routable and gets TLS
inquir apps domain shop shop.example.com --verify

inquir apps domain shop                            # list bound hostnames
inquir apps domain shop shop.example.com --remove  # unbind

Custom domains are always production hostnames and follow the promoted release from the moment they verify; you can add one before or after the first promote. Platform-owned names (the apps/preview suffixes, the marketing and dashboard hosts, hostnames already claimed by a gateway custom domain) are refused. Domain changes move production traffic, so they need an owner or admin.

Environment variables

Set env vars on the application (--set KEY=VALUE, the Variables section in Settings, or envVars in the API). Values are encrypted at rest, masked on every read, and frozen into each release when it is created — changing a variable affects the next release, not a running one. Redeploy ("Deploy again") to pick up new values.

Keys listed in productionOnlyEnvKeys are withheld from a release while it runs under the preview profile and injected only when it is promoted (the container is recreated under the production profile first). Use it for production credentials, so a preview URL handed to a teammate or an agent cannot carry them. PORT is injected automatically from the primary HTTP port.

Outbound network

--egress none|full (networkPolicy.egress) sets whether the container may open outbound connections at all. none puts it on an isolated network with no route out — right for a database that should only be dialled, wrong for anything that calls an API. Per-host allowlists are available for functions but not yet for applications; the API answers 400 for allowlist here rather than accepting a policy the release could not honour.

Quotas and defaults

Every workspace has a ceiling on live releases and on resources per release; the create form and GET /v1/apps/limits report the values in force. Platform defaults:

LimitDefault
Live releases per workspace (previews + production + reserve)10
Memory per release512 MB (ceiling 2,048 MB)
CPU per release0.5 vCPU (ceiling 2 vCPU)
Startup health-check deadline120 s
Drain window (rollback reserve)5 min
Preview release lifetime24 h
Restarts before a release is marked failed (crash loop)5

Roles and API keys

Any workspace member — including the developer role — can create applications, deploy releases, watch logs, stop non-production releases and edit settings. Promote, roll back, custom domains, archive and exec need an owner or admin who is logged in (a browser session or a personal access token minted by inquir login). Actions the current role cannot perform are shown disabled with the reason.

Workspace API keys cannot promote, however permissive their role: promotion and exec require a user identity. That is deliberate — a CI or agent key with the deploy scope can build and ship previews and read their status and logs, but cannot move production traffic or read the secrets a live container holds.

REST API

Everything the CLI and dashboard do goes through /v1/apps…. Long operations answer 202 and expose a status URL: a release row carries the bare image ref until the digest is pinned, then imageDigest flips to sha256:….

EndpointPurpose
POST /v1/apps, GET /v1/appsCreate an application (env values are masked on every read) / list, cursor-paginated.
GET /v1/apps/:id, PATCH /v1/apps/:id, DELETE /v1/apps/:idRead (production pointer, URLs, previews, domains, recent releases) / update — masked env values are merged, not overwritten / archive.
POST /v1/apps/:id/releasesCreate a release from {buildId} or {imageRef, runtime?} → 202; the pull and health gate run in the background.
GET /v1/releases/:id, GET /v1/releases/:id/logsRelease status projection / SSE container logs.
POST /v1/releases/:id/promote, POST /v1/apps/:id/rollbackMove production traffic to a release / back to the previous one (200 instant, 202 restarting). Owner/admin login only.
DELETE /v1/releases/:idStop any non-production release; the routed one answers 409.
POST /v1/releases/:id/execRun a command inside the live container (output capped at 1 MiB; the timeout kills the process tree). Owner/admin login only.
POST /v1/apps/:id/domains, POST …/domains/:hostname/verifyBind a custom hostname (unverified, returns the TXT record) / DNS ownership check → routable + TLS.
POST /v1/apps/:id/builds, GET /v1/builds/:id/logsBuild an image from a source snapshot or a base64 zip → 202 / stream build logs; POST /v1/builds/:id/cancel drops a queued build.
POST /v1/apps/:id/backups, GET /v1/apps/:id/backupsSnapshot every volume of the production release (<code>202</code> — the copy runs in the background) / list restore points, newest first.
POST …/backups/:backupId/restore, DELETE …/backups/:backupIdRestore a snapshot over its volume, stopping and restarting the application / drop a restore point. Owner or admin.
POST /v1/apps/:id/connection-url, POST /v1/apps/:id/connectThe live connection URL of a template database / write it into another application's environment. Both are <code>POST</code> and never cached: this is the one place the API hands back a stored credential.
GET /v1/apps/limitsQuotas, defaults and the host-port range in force for this workspace.
curl
# Create an application (API key or login)
curl -X POST "https://api.inquir.org/v1/apps" \
  -H "Authorization: Bearer $INQUIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"web","runtime":{"port":3000}}'

# Release a prebuilt image — 202; the pull and the health gate run in the background
curl -X POST "https://api.inquir.org/v1/apps/{appId}/releases" \
  -H "Authorization: Bearer $INQUIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"imageRef":"nginx:alpine"}'
# -> { "id": "…", "status": "PENDING" }   poll GET /v1/releases/{releaseId}

# Promote — needs a user session or personal access token; API keys are refused
curl -X POST "https://api.inquir.org/v1/releases/{releaseId}/promote" \
  -H "Authorization: Bearer $INQUIR_PAT"
# -> { "productionUrls": ["https://web.apps.inquir.org"], "previousReleaseId": "…" }

The compute plane: an ECS without the ECS

Behind every application sits an orchestration plane in the spirit of AWS ECS — capacity, placement, health and recovery are the platform's job, not yours. Machines (workers) register with the control plane and heartbeat; your releases are placed onto them as fenced allocations; the gateway routes traffic straight to the containers wherever they run.

  • Portable releases. Every build is pushed to the platform registry and pinned by digest, so the exact same image starts on any worker — a release is bytes, not a machine.
  • Placement. The scheduler picks a worker by capacity (bin-pack on one machine, spread across several); replicas of one application balance per request.
  • Health and replicas. A release serves only after its healthcheck passes on the interface traffic actually reaches. Scale always apps up to 8 replicas; failed containers restart with exponential backoff.
  • Recovery. A lost worker is detected by lease expiry and its allocations are relocated; worker restarts keep serving containers running and re-adopt them — traffic does not notice.

None of this requires configuration: inquir apps deploy builds, publishes, places and promotes. Adding a machine to the pool is one installer run on a box with Docker — see the runner deployment guide in the repository.

Limitations

  • One replica per release. A restart — crash recovery, or the recreate that promotion performs when production-only variables are withheld — means a short window of 503 on that release's hostnames. Nothing is cut over on the production hostname until the new container is healthy.
  • Persistent volumes are local to one runtime host. An application with a volume is pinned to the host that holds it: no replication, no live migration, and backups are the only copy of the data that leaves that host. Removing a mount or archiving the application retains its Docker volume for operator cleanup.
  • No autoscaling. Resources are fixed per release (--memory, --cpu); scale by promoting a release with a larger ceiling.