---
title: Deployment
summary: A deployment is one process tree serving the API, the agent endpoint and the documentation for its own revision, bound to all interfaces so that tailnet machines can reach it. Deploy natively, as a container, or under systemd.
section: guide
order: 8
level: 2
audience: [operators]
keywords: [deployment, tailnet, docker, systemd, GPU, token]
---
<!-- gyre docs: expanded Markdown of guide/deployment.md (generated tables, verification numbers and chart data are written out). Base level of this page: L2 Practice; passages at another level are bracketed by 'begin level N' / 'end level N' comments and start with [LN Name]. Built from revision ceb1f4b0be. -->

## Native deployment

```bash
python3 tools/deploy.py up --name dev --port 8600     # make deploy
python3 tools/deploy.py status
python3 tools/deploy.py down --name dev
```

`up` performs, in order: refuse a name that is still running and a port that is already in use
(`make serve`, the systemd unit and other deployments default to 8600 too) → record the
revision (commit, dirty flag) → build the documentation into the deployment directory → start
`gyre serve --host 0.0.0.0` → wait for health and verify through `/api/v1/info` that the answer
comes from the process just started → run the smoke test against the tailnet address (loopback
if the host has none) → write `var/deploy/<name>/deployment.json`, which records whether the
smoke test passed. No record is written if the service has exited by the end of the smoke test.

<!-- begin level 2 (Practice) -->
**[L2 Practice] What the smoke test covers**

Health; deployment identity; the documentation home page, search index and `llms.txt`; presets;
a structured error; pipeline definitions (at least one runnable, none unloadable); the MCP tool
list; a real solver job with an adjoint gradient, submitted with `use_cache: false` (an answer
from the result cache fails the check); metrics. It uses only the Python standard library and
can be run from any tailnet machine:

```bash
python3 deploy/smoke.py http://HOST:8600
GYRE_API_TOKEN=... python3 deploy/smoke.py http://HOST:8600     # token-protected deployment
```

A deployment protected by `GYRE_API_TOKEN` is tested with the same variable set for the script,
or with `--token`. `up` passes its environment on to the service (without `GYRE_DEVICE`, which
the service sets per worker) and to the smoke test, so setting the variable for `up` covers
both.

<!-- end level 2 -->

<!-- begin level 2 (Practice) -->
**[L2 Practice] Reaching it over the tailnet**

The service binds `0.0.0.0`, never loopback only. Every URL below works from another
tailnet device with the host's tailnet name or address:

| URL | |
|---|---|
| `http://HOST:8600/` | documentation built from the deployed revision |
| `http://HOST:8600/api/v1/info` | which revision and deployment is answering |
| `http://HOST:8600/api/docs` | interactive API explorer |
| `http://HOST:8600/mcp` | agent endpoint |

<!-- end level 2 -->

## Container

```bash
GYRE_COMMIT=$(git rev-parse HEAD) docker compose -f deploy/compose.yaml up -d --build
```

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] Build arguments and environment**

| Name | Purpose |
|---|---|
| `GYRE_CONSTRAINTS` | the tested dependency set to install: `constraints/tested-cpu.txt` (default; JAX 0.11.2, which also runs on CPUs without AVX2) or `constraints/tested-cuda12.txt` for NVIDIA GPUs |
| `GYRE_EXTRAS` | extras of the package to install: `service,docs` (default), `service,docs,gpu` with the CUDA set |
| `GYRE_COMMIT` | revision stamped into the image and its documentation |
| `GYRE_PORT` | published host port (default 8600, bound on all interfaces) |
| `GYRE_API_TOKEN` | require a bearer token |
| `GYRE_SERVICE_GPU` | `auto`, `on` or `off` (default `off` in the container) |
| `GYRE_PIPELINES_ALLOW_SHELL` | `1` lets the service start pipelines with `shell` stages (see [Configuration](#configuration)). The compose file forwards it from the environment `docker compose` is started in (`GYRE_PIPELINES_ALLOW_SHELL=1 docker compose … up`); nothing needs to be added to the file |

The image builds the documentation at image-build time from the verification results present
in the build context, so a container and its documentation cannot disagree. Job data and the
JIT cache live on the `/data` volume.

`make image` (`deploy/image_smoke.sh`) builds the image and smoke-tests a throw-away
container of it on port 8602. On a machine whose Docker build sandbox cannot resolve host
names (the package download fails with "Temporary failure in name resolution" although
`docker run` works), set `GYRE_BUILD_NETWORK=host` for that script, or pass
`--network host` to `docker build`.

The image also ships the analysis pipeline definitions of its revision (`pipelines/` without
`ci.yaml`) and `examples/` under `/app`; the OpenMDAO example needs the `mdo` extra, which the
image does not install. To serve your own pipeline definitions, bind-mount a directory over
`/app/pipelines` (`deploy/compose.yaml` carries a commented example).

<!-- end level 3 -->

## systemd

```bash
cp deploy/gyre.service ~/.config/systemd/user/
systemctl --user enable --now gyre
```

## Configuration

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] Environment variables**

| Variable | Default | Effect |
|---|---|---|
| `GYRE_DEPLOYMENT` | `dev` | name reported by `/api/v1/info` and shown in the documentation footer |
| `GYRE_API_TOKEN` | unset | bearer token for `/api/v1` and `/mcp` (`/api/v1/health` and `/api/v1/info` stay open) |
| `GYRE_SERVICE_GPU` | `auto` | whether workers may use a GPU |
| `GYRE_MAX_SESSIONS` | 4 | concurrent sessions |
| `GYRE_SESSION_IDLE_S` | 1800 | idle time before a session is closed |
| `GYRE_PIPELINES_DIR` | `pipelines` | directory of the [pipeline](pipelines.md) definitions the service lists, relative to its working directory (`/app/pipelines` in the image) |
| `GYRE_MESH_MAX_BYTES` | 67108864 (64 MiB) | largest mesh file `PUT /api/v1/meshes/{name}` accepts (413 `MeshTooLarge` above it); the arrays of an `.npz` upload may take up to four times this value uncompressed and must also stay within `GYRE_MESH_MAX_EXPANDED_BYTES` (422 `InvalidMesh`). Read at each request. A value that is not a positive integer is replaced by the default |
| `GYRE_MESH_STORE_MAX_FILES` | 256 | number of meshes the mesh store holds (507 `MeshStoreFull`). A value that is not a positive integer is replaced by the default |
| `GYRE_MESH_STORE_MAX_BYTES` | 4294967296 (4 GiB) | total size of the mesh store in bytes (507 `MeshStoreFull`). A value that is not a positive integer is replaced by the default |
| `GYRE_MESH_DIR`, `GYRE_MESH_RESTRICT` | set by the service | while it runs, the service sets `GYRE_MESH_DIR` to `<data>/meshes` (absolute path) and `GYRE_MESH_RESTRICT=1` in its own environment, whatever they were before; its job workers, session processes and pipeline runs inherit them and open mesh files only by bare name from that directory. Outside a service, `GYRE_MESH_DIR` is a directory in which the CLI and Python look for a relative mesh path that does not exist as given |
| `GYRE_MESH_MAX_EXPANDED_BYTES` | 1073741824 (1 GiB) | largest uncompressed size of the arrays of an `.npz` mesh the reader accepts, in any process, the service included (an upload above it is a 422 `InvalidMesh`). A value that is not a positive integer is an error: every read of an `.npz` mesh fails with `GYRE_MESH_MAX_EXPANDED_BYTES must be a positive integer, got '…'` |
| `GYRE_PIPELINES_ALLOW_SHELL` | unset | `1` lets the REST API and the MCP tools start pipelines that contain `shell` stages (for example `ci`). Without it such definitions are listed with `runnable: false` and a `reason`, and starting one returns 403 `PipelineNotRunnable`; they stay runnable on the host with `gyre pipeline run` |
| `GYRE_DEVICE` | `auto` | device of a solver process (the service sets it per worker) |
| `GYRE_JIT_CACHE` | `~/.cache/gyre/jit` | persistent compilation cache directory, or `off` |
| `GYRE_LOG_LEVEL` | `info` | uvicorn log level |
| `GYRE_SERVICE_URL` | `http://127.0.0.1:8600` | read by the stdio MCP proxy only (`python -m gyre.agent`): the deployment it forwards to when `--service` is not given ([agent interface](agents.md)) |

`gyre serve --workers N` sets the number of concurrent job workers (GPU jobs are serialised).

<!-- end level 3 -->

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] Operations**

* **Identity.** `GET /api/v1/info` returns version, commit, process id, start time and deployment name; the
  documentation footer shows the same. `up` accepts the answer only if it reports the
  deployment's name and a start time not earlier than the launch; otherwise it stops the
  process it started. It passes the name and start time on to the smoke test, which stops at
  once if another process answers.
* **Monitoring.** Scrape `GET /api/v1/metrics`. An unexpected fault is answered 500
  `InternalError` with an incident id; the service log (logger `gyre.service`) has the
  exception under the same id.
* **Mesh uploads.** Without `GYRE_API_TOKEN`, anyone who reaches the service can upload meshes
  up to the limits above. Uploads are validated before they are stored and are addressed by
  name only; a request cannot make the service read or write a path outside `<data>/meshes`.
* **Data.** The data directory (`gyre serve --data`, default `var/service`) holds
  `jobs/<id>/` (request, status, events, artefacts), `cache.json`, `meshes/` (the
  [mesh store](meshes.md#meshes-in-a-service): one file per uploaded mesh) and `pipelines/`
  (`runs/<id>/` per run and `work/`, the stage directories and their cache). Deleting a job
  directory is safe. Mesh files can be copied into `meshes/` or removed by hand; a file the
  reader rejects is listed with an `error`. The result
  cache is keyed by the case specification, the requested gradients, error estimates and order
  selection, the contents of a mesh file the case names, and a
  fingerprint of the code (version, revision and the package sources). An upgrade can therefore
  keep its data directory: results computed by other code are not reused, and old job records
  stay readable but are never served as cache hits. `<data>/cache.json` is only an index of
  that cache; the job records are the source of truth.
* **Upgrades.** Deploy the new revision under a new name and port, smoke-test it, then stop the
  old one: `tools/deploy.py` keeps deployments side by side. `up` refuses a port that is
  already in use, so the new revision needs its own `--port`.

<!-- end level 3 -->
