---
title: "Offline install"
description: "Run a cluster with no way out. One folder holds a release, its toolchain, a signed plugin index, plugin bundles and the PyPI and npm packages they install; the edge serves it, and nothing dials out."
canonical_url: "https://docs.oz0.ai/fleet/offline-install"
---
# Offline install

> Run a cluster with no way out. One folder holds a release, its toolchain, a signed plugin index, plugin bundles and the PyPI and npm packages they install; the edge serves it, and nothing dials out.

For: cluster operators.

Prepare the signed release and required toolchains on a connected machine, then make them available through the edge.

A cluster in a network with no way out installs everything from one offline folder: the node binaries and runtime, what nodes install before their runtime (uv, Python, wheels, Node.js), a signed plugin index, the plugins it lists, the PyPI and npm packages they need and the OCI images their tools run. You copy the folder in by whatever means your site uses (a disk, a data diode, an internal file share), point the edge at it, and nodes install from the edge only. Nothing in this path tries the network.

## The folder

```text
oz0-offline.json            every file below, with its size and SHA-256
oz0-offline.json.sig        signatures of oz0-offline.json (optional)
dist/                       a release's dist folder: manifest.json(.sig), node/, runtime/, toolchain/
index/index.json            a signed plugin index
plugins/sha256/<hex>        plugin bundles, named by their SHA-256
oci/blobs/sha256/<hex>      OCI manifests, configs and layers by digest
pypi/files/<file>           wheels and source distributions
npm/<name>/-/<name>-<version>.tgz   npm tarballs (<name> may be @scope/name)
sbom/ licences/ advisories/ for people and tools; the server reads none of them
```

`oz0-offline.json` lists every file the folder holds. Paths are relative, with forward slashes, never `.` or `..`, and only under the folders above. Its format is `oz0-offline-v1`:

```json [oz0-offline.json]
{
  "format": "oz0-offline-v1",
  "created": "2026-10-09T12:00:00Z",
  "index": "index/index.json",
  "files": {
    "pypi/files/requests-2.32.5-py3-none-any.whl": {"sha256": "2462…", "size": 64738}
  }
}
```

Any tool may write a folder in this layout; a folder made by hand works too.

`advisories/advisories.json`, when a folder carries it, is what an operator imports for the [vulnerability scan](https://docs.oz0.ai/build/plugins/vulnerabilities#where-advisories-come-from) with `oz0 index advisories import --file`; the server never reads it by itself.

## What is checked

The server trusts nothing for being in the folder. Every file it takes is checked against something it already trusts:

<table>
<thead>
  <tr>
    <th>
      What
    </th>
    
    <th>
      Checked against
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Every listed file
    </td>
    
    <td>
      Its size and SHA-256 in <code>
        oz0-offline.json
      </code>
      
      , when the edge reads the folder. When <code>
        oz0-offline.json
      </code>
      
       changes, the edge reads again only the files that are new or changed on disk, and serves the last good reading meanwhile. A file that does not match, is a link, or is not listed is never served, and the log says why
    </td>
  </tr>
  
  <tr>
    <td>
      The release
    </td>
    
    <td>
      <a href="#offline-release-verification">
        Details
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      The toolchain
    </td>
    
    <td>
      The checksums pinned in the node binary (uv, Node.js), in uv (Python) and in the runtime's <code>
        requirements.txt
      </code>
      
       (wheels)
    </td>
  </tr>
  
  <tr>
    <td>
      The plugin index
    </td>
    
    <td>
      The root key you pin when you import it; its version may not go back, and an expired copy stops installs
    </td>
  </tr>
  
  <tr>
    <td>
      Plugin bundles and OCI blobs
    </td>
    
    <td>
      The SHA-256 or digest the signed index lists
    </td>
  </tr>
  
  <tr>
    <td>
      PyPI and npm packages
    </td>
    
    <td>
      The hashes the edge pins at install (<a href="https://docs.oz0.ai/build/plugins/packages#pinning-and-hashes">
        pinning and hashes
      </a>
      
      ), and the plugin's own <code>
        uv.lock
      </code>
      
       or <code>
        package-lock.json
      </code>
    </td>
  </tr>
</tbody>
</table>

So `oz0-offline.json` needs no signature for the server to use the folder safely: the release in it is checked against the release keys, and everything else against something the server already trusts. A signature over `oz0-offline.json` protects the transfer as a whole; check it with your own tooling before you copy the folder in.

## Serve it from the edge

Take the release and the toolchain into the edge's dist directory, then start the server with the folder:

```bash [Terminal]
oz0 server dist pull --from /srv/oz0-offline --dist-dir /var/lib/oz0/dist
oz0 server start ... --offline-dir /srv/oz0-offline --dist-dir /var/lib/oz0/dist
```

`--offline-dir` (or `OZ0_OFFLINE_DIR`) needs `--dist-dir`: pull the folder's release into the edge's dist folder first. A start with `--offline-dir` alone stops and says how. Whatever folder `--dist-dir` names, the edge serves its release only once it verifies, is on the channel the cluster follows and is no older than a release it served, before or since a restart, or than what management told the cluster's nodes to run, unless a cluster admin allowed going back to that version ([Upgrades](https://docs.oz0.ai/fleet/upgrades)). With both, the edge:

- serves the release and the toolchain `dist pull` put in its dist folder on its usual routes, so [new nodes](https://docs.oz0.ai/fleet/join-nodes) install from it;
- serves a PyPI mirror (`/v1/offline/pypi/`: PEP 503 and PEP 691 pages, the files, and each release's dependencies) and an npm mirror (`/v1/offline/npm/`: packuments and tarballs) to the cluster's nodes only, answering only for files the folder lists;
- pins every `pypi:` and `npm:` package against those mirrors, in the server's own process: `OZ0_PLUGIN_PYPI_URL` and `OZ0_PLUGIN_NPM_URL` do not apply;
- takes plugin bundles and OCI blobs (OCI plugins and tools' images) from the folder by digest, never from the address a listing names;
- refuses a plugin whose packages the folder lacks, at install, naming each package, its version and the hashes it needs. A folder can serve only what was locked: the hashes the edge pins decide whether a node installs a file.

Plugin indexes added by URL are not fetched from an offline edge. Git sources stay as they are: a Git server is yours to name.

<warning>

The mirrors are the cluster's, not a tenant's: every admitted node of every tenant can list and download everything in the folder's `pypi/` and `npm/`, and a plugin running on a node can read them through the node's proxy. Put private packages in a folder only on a cluster with one tenant, or when every tenant may see them.

</warning>

## Import the index

Import the folder's index from its file, with the root key its publisher gave you:

```bash [Terminal]
oz0 index add airgap --file /srv/oz0-offline/index/index.json --root-key <root key>
oz0 plugin install hub:com.example/weather@1.4.0
```

An imported index is never fetched, in any mode. Each new copy comes from a file:

```bash [Terminal]
oz0 index refresh airgap --file /srv/oz0-offline-2026-11/index/index.json
```

A copy older than the version the cluster has seen is refused as a rollback, and the same version with other contents is refused too. See [Indexes](https://docs.oz0.ai/build/plugins/indexes/manage#offline).

## Nodes

Run nodes with `oz0-node run --offline` (or `OZ0_NODE_OFFLINE=1`). An offline node installs uv, Python, its runtime and Node.js from the edge, and, when an edge serves an offline folder, every plugin package from the edges' mirrors through its loopback proxy: uv with the edge's hashes (`--require-hashes`), a locked Python project from the mirror's files, `npm ci` with each tarball's integrity, and a plugin's `package-lock.json` as npm wrote it (npm replaces `registry.npmjs.org` with the edge's mirror). A node that is not offline is unchanged.

A [release age](https://docs.oz0.ai/build/plugins/packages#lockfiles) set on a node meets the folder differently for each ecosystem. The folder's npm mirror keeps no publish dates, so a node using it with an npm age refuses every registry pin of a plugin's own `package-lock.json` as undated: leave the npm age off such nodes, where what the folder holds is what you chose to pack. A locked Python project installs from the folder's files with its hashes checked, and the age is not asked.

An offline node that has not yet found the mirrors (an edge restarting, or one that has not yet seen the node admitted) asks the edges again before an install, at most every 15 seconds, so it needs no restart. Its proxy passes over an edge that serves no folder for the next one, so only some of a cluster's edges need `--offline-dir`.

## A new release

Copy in a new folder, then pull its release and point the edge at it:

```bash [Terminal]
oz0 server dist pull --from /srv/oz0-offline-2026-11 --dist-dir /var/lib/oz0/dist
oz0 node upgrade --binary
```

The release's signature is checked before any file it names is read; every file, the toolchain's included, is checked against its digest in a staging folder inside the dist folder; new files go in place beside the served ones, the release's manifest after them, and only then a file that replaces one on disk and the toolchain's manifests.

A pull that fails at any step leaves every file the edge served as it was. A release that names no node version does not replace one that does unless `--allow-older` says so. A release served from object storage that answers 403 for a missing `toolchain/toolchain.json` is pulled without a toolchain, with a warning.

## What it does not cover

- Git listings of an index: a Git source needs its server. Pack such a plugin as a bundle (`oz0 plugin pack`) and install that.
- Harness programs that a manifest's `binaries` download, and anything a tool's own code downloads when it runs.
- PEP 658 metadata files: uv reads a wheel's metadata with a range request instead.
- Files over 4 GB, and folders of more than 200,000 files: the edge refuses a manifest that lists one. Whoever packs the folder may set lower limits of its own.

[Outbound connections](https://docs.oz0.ai/concepts/outbound-connections) says, for each host, how an offline folder turns it off. `make e2e-airgap` runs a cluster and an offline node from such a folder in a network namespace where any other connection fails the test.

### Offline release verification

The release keys built into the server and node binaries ([Releases](https://docs.oz0.ai/fleet/releases)): `server dist pull --from` takes the folder's `dist/` into the edge's dist folder only once its signature verifies, it is of the server's flavour and on the cluster's channel, and it is no older than what the dist folder serves (overrides take a reason).

The edge serves a release only while its manifest verifies, it is on the cluster's channel and no older than the release it served before or than its nodes run (unless a cluster admin allowed that version), and each file only with the digest the signed manifest gives it


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
