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.
What decides that this is a service
Section titled “What decides that this is a service”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
Dockerfileat its root; - a server the build recognises without one: a server framework in
package.jsondependencies(Express, Fastify, Koa, Hono, NestJS, Next.js, Nuxt, Remix, React Router, Astro’s Node adapter, and others — SvelteKit’s Node adapter counts fromdevDependenciestoo, where its templates put it) or a"start"script that runsnode,tsxorbunon a file; Django, Flask, FastAPI, Gunicorn or Uvicorn among the run-time requirements inrequirements.txt,pyproject.tomlor aPipfile, or amanage.py; Rails, Sinatra or Puma in theGemfile, or aconfig.ru;go.modwith Go code;composer.jsonorindex.php;Cargo.tomlwithsrc/main.rs;pom.xmlor Gradle; a.csproj;mix.exs; aProcfilewith aweb:process.
The CLI names what it saw:
→ package.json depends on express: this directory is a service, built on ToriOnly 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:
--servicebuilds and runs the directory:npx tori.host deploy --service;--staticpublishes files instead:npx tori.host deploy --staticpublishes the output directory (building it first when there is abuildscript), and stops rather than publish a directory of server code; name a directory to publish exactly that one,npx tori.host deploy --static .included;--imageruns 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.
Deploy from source
Section titled “Deploy from source”From the project root:
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.
What is uploaded
Section titled “What is uploaded”The directory as it is, minus:
.gitandnode_modules, at any depth;- whatever
.gitignorematches — 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.
How it is built
Section titled “How it is built”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.
Build cache
Section titled “Build cache”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.
When a build fails
Section titled “When a build fails”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…npx tori.host deploy build-log d_7f3a… | lessThe 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.
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.
Digest-pinned images
Section titled “Digest-pinned images”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 tagResolve a tag once, then deploy the digest:
docker inspect --format "{{index .RepoDigests 0}}" my-image:tagnpx 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.
Release-phase migrations
Section titled “Release-phase migrations”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:
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.
If that image and environment are already live, the CLI reports already live and does not create a new revision. --restart forces one:
npx tori.host deploy --image "$IMAGE" --restartContainer requirements
Section titled “Container requirements”The process must:
- listen on port 8080, or on the port you name for the deploy (below)
- declare a numeric non-root
USER, for exampleUSER 1000
Another port
Section titled “Another port”A container that listens elsewhere names its port for the deploy, with
--port or with "port" in tori.json:
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):
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.
When the container exits on start
Section titled “When the container exits on start”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.
Environment
Section titled “Environment”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.
Plan changes
Section titled “Plan changes”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.
Idle apps take naps
Section titled “Idle apps take naps”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.
Watch, cancel, recover
Section titled “Watch, cancel, recover”Ctrl+C stops watching; the deploy continues on the server. Resume or cancel with the ID the CLI printed:
npx tori.host deploy statusnpx tori.host deploy watch d_examplenpx tori.host deploy cancel d_exampleCancellation 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.
Failures
Section titled “Failures”| 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.