---
title: Deploy a container service
description: Build from source or run a digest-pinned image. The zone runs the container.
---

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

Three things make a service:

- a `Dockerfile` at the root of the directory you deploy — `npx tori.host` sees it and needs no flag;
- `--service`, for a project with no Dockerfile: `npx tori.host deploy --service`;
- `--image`, for an image you built elsewhere.

Anything else is a static site. That is on purpose: a project that *could* run as a server (a `package.json` with a start script) is not turned into one by guesswork.

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 `--service` is only 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

From the project root:

```sh
npx tori.host deploy --service
```

```
→ packing . (no Dockerfile: the builder detects the language)
→ packed 23 files, 41.2 kB
→ uploaded 41.2 kB in 0s (312.0 kB/s)
→ queued for build — position 2 in the build queue
→ building — attempt 1
→ built registry.tori.zone/tenant-a_6d9009a4ef/app@sha256:9f2c…
✓ live: https://plucky-wren.tori.cloud
```

`live` means the revision built from this upload is Ready and serving. The image line names what was built, pinned to its digest; `tori deploy status` shows it again later.

### What is uploaded

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.

### How it is built

With a `Dockerfile` at the root, that Dockerfile is built. 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.

### When a build fails

Nothing changes for the version that is live. 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
  Full build log: tori deploy build-log d_7f3a…
```

```sh
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.

```sh
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 then prints `The API did not confirm cancellation` and exits non-zero until the builder acknowledges. 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

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:

```sh
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:e3b0c442…
⠹ starting… (6s)
✓ live: https://plucky-wren.tori.cloud
```

`live` means the revision the zone started is Ready and serving. Pass `-v` / `--verbose` to print the deploy ID, recovery commands, and phase timings.

## 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:

```sh
npx tori.host deploy --image "$IMAGE" --release "npx prisma migrate deploy"
```

```json
{ "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](/guides/environment/#release-phase-migrations).

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

```sh
npx tori.host deploy --image "$IMAGE" --restart
```

## Container requirements

The process must:

- listen on **port 8080**
- declare a **numeric non-root `USER`**, for example `USER 1000`

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):

```sh
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.

## 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](/guides/environment/).

## 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.

## Watch, cancel, recover

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

```sh
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](/guides/deploys/).

## 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` |
| `deploy … is already in progress` | `tori deploy watch <id>` or `tori deploy cancel <id>` |
| `deploy failed: …` | The zone stored a reason (image pull, `runAsNonRoot`, crash on start, listen port, or a failed source build). Fix it, then deploy again |
| App never answers on 8080 | The probe fails and the revision does not become Ready. Listen on 8080 |
| 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](/guides/static-deploy/#custom-hostnames). Managed Postgres is not generally available. Logs are; see [Logs](/guides/logs/).

See [tori deploy](/cli/deploy/) for the flags and [the API](/api/) for the HTTP side of the same flow.

Download this page as [Markdown](/downloads/markdown/guides/service-deploy.md).
