Skip to content

Deploy a container service

A service app is a container the zone runs for you. You can upload source and have Tori build it, or bring an image you already built. Which kind an app is is decided when it is created and cannot be changed afterwards.

On the first deploy — no tori.json yet — the directory you deploy decides, and npx tori.host needs no flag when it is clear:

  • a Dockerfile at its root;
  • a server the build recognises without one: a server framework in package.json dependencies (Express, Fastify, Koa, Hono, NestJS, Next.js, Nuxt, Remix, React Router, Astro’s Node adapter, and others — SvelteKit’s Node adapter counts from devDependencies too, where its templates put it) or a "start" script that runs node, tsx or bun on a file; Django, Flask, FastAPI, Gunicorn or Uvicorn among the run-time requirements in requirements.txt, pyproject.toml or a Pipfile, or a manage.py; Rails, Sinatra or Puma in the Gemfile, or a config.ru; go.mod with Go code; composer.json or index.php; Cargo.toml with src/main.rs; pom.xml or Gradle; a .csproj; mix.exs; a Procfile with a web: process.

The CLI names what it saw:

→ package.json depends on express: this directory is a service, built on Tori

Only specific signs count, because the choice is permanent. A start script that runs react-scripts, a Jekyll Gemfile, a Hugo site’s go.mod, Next.js with output: 'export' and Nuxt whose build script is nuxt generate stay static sites, and so does a framework listed only as a development dependency.

A Dockerfile always makes a service, whatever else the directory holds. Without one, a directory that looks like a server and holds an index.html the static path would publish — at its root, or in dist/, build/, out/ or _site/ — is not guessed either way. At a terminal the CLI asks; in CI or from a script it stops before creating anything and prints the commands that fit. Publishing the files is offered only when they are in an output directory of their own, or when a build script will put them in one (a root index.html that Vite builds from, or a Create React App or Vue CLI template in public/; not a server’s own tsc, and not a full-stack framework such as Remix, whose build writes a server): an index.html beside the server’s code would otherwise publish that directory, source and .env included, so the CLI offers to build the service, or to deploy a directory that holds only the site with --static <dir>.

The flags answer it, and override detection anywhere:

  • --service builds and runs the directory: npx tori.host deploy --service;
  • --static publishes files instead: npx tori.host deploy --static publishes the output directory (building it first when there is a build script), and stops rather than publish a directory of server code; name a directory to publish exactly that one, npx tori.host deploy --static . included;
  • --image runs an image you built elsewhere.

Everything else is a static site, as it always was.

The first deploy writes tori.json into the directory that was packed — the service’s own directory, also when it was named as tori deploy --service ./services/api from a monorepo root. Later deploys read it there. A service is built from source whenever --image is absent, so a flag is only ever needed the first time. tori deploy status, build-log, logs and env read the same file, so run them from the service’s directory.

From the project root:

Terminal window
npx tori.host deploy --service
→ packed . (23 files, 41.2 kB; no Dockerfile, the builder detects the language)
→ uploaded 41.2 kB
→ queued for build (position 2)
→ building (attempt 1, railpack)
→ built in 1m15s; starting the revision
✓ live: https://plucky-wren.tori.cloud (1m28s)

live means the revision built from this upload is Ready and serving. Serving is checked, not assumed: the zone’s gateway has to answer for the new revision at your app’s HTTPS address before the deploy is reported live. Until it does, the deploy stays in progress and -v shows a progress message saying it is waiting for the gateway. tori deploy status names the image that went live, pinned to its digest; -v prints it during the deploy, along with the deploy id, the server’s progress messages and the phase timings.

The directory as it is, minus:

  • .git and node_modules, at any depth;
  • whatever .gitignore matches — build output, .env, target/, the things that stay out of the repository stay out of the upload;
  • tori.json.

Every .gitignore git itself reads applies: the ones above the directory up to the repository root (a monorepo’s top-level .env rule covers each service under it), the directory’s own, and the nested ones, the deeper file winning. Outside a repository only the packed tree’s files are read. Symlinks and file modes are kept, so ./scripts/build.sh is still executable when the build runs it. A .dockerignore is applied by the build next to the Dockerfile, not by the upload.

The plan caps the compressed upload and the time a build may run; the server names the cap when either is hit and the CLI prints it.

With a Dockerfile at the root, that Dockerfile is built; its Docker Hub images (FROM node:22, FROM nginx) are pulled through Tori’s cache of Docker Hub, and straight from Docker Hub only when that cache cannot serve them. Without one, the build detects the language — Node, Python, Go, PHP, Ruby, Java, Rust, Deno, .NET, Elixir and others — and produces an image that listens on port 8080 as a non-root user. A tree with neither a Dockerfile nor a recognisable language fails with a message naming what was looked for.

A Dockerfile has to meet the container requirements below itself. A detected build is arranged that way for you.

A detected build runs on base images Tori provides, pulled from Tori’s own registry rather than from the internet; PHP’s base images (dunglas/frankenphp and composer) come through Tori’s cache of Docker Hub. A railpack.json in the tree may still name a base image or a step input image, but only one on Docker Hub, which is pulled through that cache; naming an image on any other registry fails the build with a message naming the image. A Docker Hub tag that does not exist fails the same way as Docker Hub being unavailable — the build is retried once and the message names the image — so check the tag first. If you need a specific base image, use a Dockerfile.

Every build runs in a fresh machine, but the results of its steps are kept: after a build, the layers it produced are stored next to the app’s images in Tori’s registry, and the next build of the same app starts from them. A step is reused when nothing it depends on changed — the same RUN line after the same earlier steps, the same dependency manifest before the install — and is run again when something did. So a second deploy of an app whose dependencies did not change skips the installs and is usually a few times faster than the first, and a Dockerfile behaves as it does with docker build: a RUN curl -O …/latest.tgz line is served from the cache until the line itself, or a step before it, changes.

The cache belongs to one app and is never shared with another app. Deleting the app deletes the cache together with the app’s images; the registry frees their storage at its next weekly cleanup. The build log says where it is (tori: build cache … is imported if present and exported after the image is pushed), and a first build’s importing cache manifest … not found is expected. Storing the cache happens after the image is pushed and takes a few seconds; a failure to store it is a line in the build log and never fails that build.

It is not free, though: the cache is kept in the same registry space as the app’s images, and that space has a limit. Only the newest cache is kept for long: when a build stores a new one, the one it replaced is removed about a day later. So the cache takes the space of the current version plus every version replaced in the last day — about one version for an app deployed once a day, more for an app deployed many times a day with changing dependencies. Old images are not removed yet, so deploying often can still fill the space, and then a deploy fails with an infrastructure error while it is full. If that happens, tell us; today the fix is on our side, not yours.

A detected build whose base images come through Tori’s cache of Docker Hub — every PHP build, and any build whose railpack.json names a Docker Hub image — uses the build cache the same way, so a second PHP deploy with an unchanged composer.lock skips composer install. A build whose log says tori: no build cache for a plan on the proxy cache … ran on a build machine that cannot use the cache for such a plan yet, and ran every step; that is on Tori’s side, and nothing in the app needs to change.

A failed deploy does not replace anything that was live; on a first deploy there is nothing live yet. The CLI prints the cause and the last lines of the build log, and where the rest is:

✗ deploy failed: Dockerfile build failed: exit status 1
…
ERROR: process "/bin/sh -c npm ci" did not complete successfully: exit code: 1
This deploy did not replace anything that was live.
Build log npx tori.host deploy build-log d_7f3a…
Terminal window
npx tori.host deploy build-log d_7f3a… | less

The log goes to stdout, whole, so it can be paged or attached to a report. Only a deploy built from source has one, and only once its attempt has ended. If the build finished while the builder could not reach Tori, the log arrives a little later: the builder keeps it and delivers it once the path is back.

A build that exits non-zero, that no provider matches, or that runs past the plan’s time limit is not retried: the result would be the same. Infrastructure failures are retried once.

Terminal window
npx tori.host deploy cancel d_7f3a…

A queued build is cancelled immediately and the CLI exits 0. A leased or running build is marked for cancellation; the CLI waits for the builder to stop, and if it has not within two minutes it exits non-zero saying the deploy is still building. The previous version keeps serving. Ctrl+C in the terminal only stops watching — the build carries on, and tori deploy watch <id> resumes.

Nothing is packed or uploaded on this path: the image is already in a registry, and all that travels is its name. Deploy from a directory with no tori.json (or a new directory) so the CLI creates a service rather than trying to run an image on an existing static site.

The reference must be pinned to a digest — registry/repo@sha256: followed by 64 lower-case hex characters. A tag is refused locally, before any request:

$ npx tori.host deploy --image nginx:latest
✗ --image must name an image by digest, and nginx:latest names one by tag

Resolve a tag once, then deploy the digest:

Terminal window
docker inspect --format "{{index .RepoDigests 0}}" my-image:tag
npx tori.host deploy --image registry.example.com/acme/api@sha256:<64 hex>

A tag can be moved onto different bytes tomorrow, so it cannot identify a deploy.

$ npx tori.host deploy --image registry.example.com/acme/api@sha256:e3b0c442…
→ deploying registry.example.com/acme/api@sha256:e3b0c44298fc
⠹ starting 6s · Creating the container and preparing its image and volumes.
✓ live: https://plucky-wren.tori.cloud (9s)

live means the revision the zone started is Ready and serving. The digest is shortened to twelve characters; -v / --verbose prints it in full, with the deploy ID and the phase timings.

A service deploy can run a command in a Job after the image is ready and before the new revision takes traffic. Set it explicitly — it is not inferred from the tree:

Terminal window
npx tori.host deploy --image "$IMAGE" --release "npx prisma migrate deploy"
{ "slug": "plucky-wren", "release": ["npx", "prisma", "migrate", "deploy"] }

The Job is bounded at 10 minutes and that bound is not adjustable. If the Job fails or times out, the previous revision keeps serving. Database changes the command already committed are not rolled back. A Prisma, Alembic or Drizzle tree without "release" only prints a hint.

See Environment variables.

If that image and environment are already live, the CLI reports already live and does not create a new revision. --restart forces one:

Terminal window
npx tori.host deploy --image "$IMAGE" --restart

The process must:

  • listen on port 8080, or on the port you name for the deploy (below)
  • declare a numeric non-root USER, for example USER 1000

A container that listens elsewhere names its port for the deploy, with --port or with "port" in tori.json:

Terminal window
npx tori.host deploy --image "$IMAGE" --port 3000
{ "slug": "amber-wren", "port": 3000 }

The flag wins over the file. The port belongs to the deploy, like the image: a later deploy without it listens on 8080 again, and a rollback runs the port the deploy it restores was made with. Ports 8012, 8013, 8022, 9090 and 9091 are held by the platform’s request proxy in every pod and are refused. The port applies to services only; a static site listens on nothing.

The zone enforces runAsNonRoot. Root images and named users whose UID Kubernetes cannot verify will not start. The upstream knative/helloworld-go image runs as root and is incompatible without rebuilding it.

A verified request-echo example (nothing is built):

Terminal window
IMAGE='ghcr.io/mendhak/http-https-echo@sha256:1721d9487ee774fd1d37de3014afd9c215df5056c125af941ee17512178129f6'
npx tori.host deploy --image "$IMAGE"

That image returns request data as JSON. It does not implement a Hello World TARGET variable.

A process that dies on startup — a syntax error, a missing environment variable, a port already in use — is reported as soon as it exits, with the exit code and where the output is:

⠹ starting 12s · The container exited on Error (code 1) before becoming ready. Read its output with tori logs.

The container is restarted with a growing back-off, because some failures recover: a dependency that is not up yet is the usual one. A restart that is running is never cut short — the attempt the back-off bought is given its full chance to answer on 8080. It is a container that has exited again, for the fourth time, without ever becoming ready that fails the deploy, rather than waiting out the ten-minute rollout deadline; the next deploy of that app is then accepted straight away. tori logs has the process’s own output; the deploy’s stored reason names the exit code.

An app that takes a long time to start but does not exit is not affected — nothing counts against it until the process dies. An out-of-memory kill is named as one (OOMKilled), which is usually a sign the workload needs less memory rather than a code fault.

tori env set KEY=VALUE stores a variable for this service. A change applies on the next deploy, not immediately. Each deploy snapshots the environment; a running revision keeps the one it started with. After env set, deploy again: npx tori.host for a source-built service, or npx tori.host deploy --image … for a digest you brought. --restart is only for forcing a revision when that image and environment are already live, and it still requires --image.

Values never come back: tori env list shows names and a mask. See Environment variables.

A service runs with the CPU and memory of the plan its account was on when the deploy was created, and scales up to the plan’s replica ceiling. Each deploy keeps those numbers for as long as it runs.

When the plan changes — an upgrade, a downgrade, or the fallback to Nestling after an unpaid invoice — Tori redeploys every running service of the account within about a minute: the same image and the same environment, sized for the new plan. The new deploy appears in tori deploys like any other, the running revision keeps serving until the new one is ready, and the new limits apply from that moment. A release command is not run again for it. A deploy already in progress when the plan changes finishes with the old size and is then redeployed once more.

A rollback is sized by the plan the account is on now, not by the plan the deploy it rolls back to was created under.

Plan changes are part of billing, which is not generally available yet; see Plans and billing.

Every current plan sets the service to scale to zero when idle. The first request after a nap starts a new replica. That can add a short wake-up delay; it is not an outage. There is no always-on replica to buy today.

The console marks a service that is asleep as napping, on the apps list and on the app’s page. The service itself wakes within seconds of a request. The console’s status follows it back to running with a lag of up to about half a minute: the zone reports the new count on its next sync, and an open page asks for it on its next refresh. A static site never naps: its files are served directly.

The API reports the same through the instances field of the app (GET /api/apps/:slug). The field holds the number of replicas that are ready (ready) and the number the platform wants running (wanted). A running service with both at 0 is napping. ready below wanted means a replica is starting, or failing to start. In that case, check the app’s logs. stale: true means the zone has not confirmed the numbers recently, so they may already be out of date; the console does not call such an app napping. instances is null for a static site, for a service the zone has not reported on yet, and for an app that is disabled or suspended.

Ctrl+C stops watching; the deploy continues on the server. Resume or cancel with the ID the CLI printed:

Terminal window
npx tori.host deploy status
npx tori.host deploy watch d_example
npx tori.host deploy cancel d_example

Cancellation applies only to pending service attempts. It prevents that attempt from going live and leaves an existing live version in place. A deploy that has already gone live cannot be cancelled. Repeating cancellation is harmless. Use the ID from your deploy, not the placeholder above.

If another attempt is already in progress, the CLI prints its ID and the watch/cancel commands (DEPLOY_IN_PROGRESS). See Deploy status and recovery.

What you see What to do
Tag refused locally Pin a digest; nothing was sent
… is a static site, and a static site cannot run an image Deploy files to that app, or start a service from a directory with no tori.json
another deploy of … is already in progress The CLI prints its ID: tori deploy watch <id> or tori deploy cancel <id>
deploy failed: … or deploy failed after the build: … The reason is stored: a failed source build, image pull, runAsNonRoot, crash on start, listen port, or a failed release command. Fix it, then deploy again
App never answers on 8080 The probe fails and the revision does not become Ready. Listen on 8080, or deploy with --port naming the port it listens on
The container exited … before becoming ready The process died on start. tori logs has its output; after three restarts the deploy fails instead of waiting out the rollout deadline
Build log npx tori.host deploy build-log <id> after a source build ends

Pull the image yourself (docker pull <digest>) before blaming Tori if the registry requires auth Tori does not have. Public images work as in the echo example.

Custom hostnames work the same way as on a static app, if the plan includes them — Deploy a static site. Managed Postgres is not generally available. Logs are; see Logs.

See tori deploy for the flags and the API for the HTTP side of the same flow.

Download this page as Markdown.