# Deploying

> Deploying morca on one Hetzner server: the scripts from a release, deploys that replace only what changed, the UI and apps without downtime, overlapping schedulers so a deploy doesn't pause runs, rollbacks, settings from hetzner.env, secrets encrypted with SOPS, rescaling the server, and what comes next.

[morca](https://morca.dev/)

[](https://morca.dev/search "Search")

On one Hetzner server, a deploy shouldn't cost a run, and a bad one should be easy to undo. The scripts that do it come with each morca release. This page is the outline; [docs/hetzner.md](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md) in the repository is the full runbook. On AWS, [orca aws](https://morca.dev/docs/cli#aws) makes and updates the stack.

## The scripts

Since 0.19.0 the deploy tooling is a release asset, so deploying needs no checkout of morca's repository. It unpacks to `orca-hetzner-<version>/`, and the scripts print that version:

```
curl -fsSL https://github.com/morca-dev/morca/releases/latest/download/orca-hetzner.tar.gz | tar -xz
cd orca-hetzner-<version>
cp hetzner.env.example hetzner.env     # fill in every line
./up.sh --dry-run                      # prints every hcloud and ssh command; runs none
./up.sh
```

`up.sh` makes the firewall, the Volume and the server, puts the settings and secrets on it and starts the containers: Postgres, the scheduler, the UI, the sampler, the apps and the Cloudflare tunnel ([First setup](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md#first-setup)).

## Deploys

```
./deploy.sh a1b2c3d                    # the image at that tag; starts again what changed
./deploy.sh a1b2c3d console=latest     # one app on another tag
./deploy.sh --all a1b2c3d              # every container again, changed or not
```

**Only what changed starts again** (since 0.18.0). `orca-stack apply` compares each container's image, env file, flags and mounts with what it runs, and leaves it alone when nothing differs; `orca-stack plan` says what an apply would do. A census-only deploy replaces the console and nothing else, and Postgres is never restarted.

**The UI and the apps without downtime** (since 0.18.0). A new UI or app starts beside the old one, takes its name on the network once it's healthy (the UI's `/healthz`), and the old one finishes its requests and stops. One that never gets healthy is removed and the old one keeps serving. Remote workers talk only to the UI, so their runs go on through a deploy.

**The scheduler.** Restarting it kills the runs it holds, so when the plan replaces it, `deploy.sh` pauses the pipelines, waits for the scheduler's running runs (not those on remote workers), backs the database up, applies, registers the pipelines from the new image and unpauses what it paused. When the plan doesn't replace the scheduler, nothing is paused.

## Apps

From 0.26.0, an app's setup lives with its code. The stack names the apps it runs, and the app's own image says what each one gets, in its [\[apps.<name>\]](https://morca.dev/docs/config#apps) table. The deploy script no longer hard-codes any of it:

```
APPS=console                      # which apps this stack runs
SECRETS_FILE=secrets.sops.yaml    # holds secrets/orca/posthog-key, if the app asks for it
```

- **Read from the image.** `morca-stack plan` and `apply` run the app image's `morca config apps`, with the stack's overlay laid on it, and write the app's env file (`/etc/orca/app-<name>.env`, mode 600) from what it declares. That file holds its `env`, the lake with `lake = true`, each secret and `PORT`; `port`, `check` and `memory_gb` set how the container runs.
- **Secrets.** A secret the app asks for, `orca/<id>`, is a key `secrets/orca/<id>` in `SECRETS_FILE`, sent to the server like the stack's other secrets. If the server doesn't have one, the deploy stops at the plan, before anything is paused or replaced. Values are never printed, and changing one replaces the app, without downtime.
- **Before 0.26.0.** `APPS=console:8080` still works: the port is used for an app whose table gives none, and a port that differs from the table's is refused. An app the image doesn't declare, or an image from before 0.26.0, gets the fixed env of before, the lake and the UI's PostHog key as `POSTHOG_KEY`, and the plan says so.
- **Per stack.** What one stack runs differently, such as an app's `env` on dev, goes in that stack's overlay: a `morca.toml`\-shaped file named by `CONFIG_OVERLAY_FILE` in `hetzner.env`, checked in the new image before a deploy changes anything ([A stack's overlay](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md#a-stacks-overlay)).

The full runbook is [Apps](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md#apps) in docs/hetzner.md ([#331](https://github.com/morca-dev/morca/issues/331), [#332](https://github.com/morca-dev/morca/issues/332)).

## Overlapping schedulers

Since 0.18.0, with `orca-stack configure OVERLAP=1` (off by default), a deploy that replaces the scheduler doesn't pause. The new scheduler starts beside the old one and takes the lead, and the old one starts nothing more, finishes its runs under its own image and exits ([#232](https://github.com/morca-dev/morca/issues/232)). A migration that raises the schema's floor refuses the overlap, and the deploy then pauses and waits as above, saying why.

## Rollbacks

```
./deploy.sh --rollback                 # back to the tags deployed before
ssh root@<ip> orca-stack apply --app console=<sha>   # only an app
```

Each deploy keeps the tags it replaced and, when it replaces anything, a database backup first (since 0.16.0). `--rollback` applies the previous tags and, if the scheduler was replaced, registers the pipelines again, so runs start with the code that's deployed. Postgres isn't rolled back: an older morca runs on a newer schema whose migrations since are additive. Only a migration that raises the floor needs the backup restored ([Rolling back](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md#rolling-back)).

## Settings and secrets

**Settings from `hetzner.env`** (since 0.18.0). Every deploy, rollbacks too, brings the server's settings and secret files in line with `hetzner.env` and prints the names of what changed, never a value. So `hetzner.env` is where a setting changes: a hand edit to `/etc/orca/settings.env` is undone by the next deploy, and an extra line belongs in `EXTRA_ENV_FILE`.

**Secrets encrypted with SOPS** (since 0.19.0). A secret file that `hetzner.env` names as `*.sops` or `*.sops.<ext>` is decrypted with `sops -d` on its way to the server and never written to a file on the laptop, so the laptop keeps one age key instead of plain tokens ([Secrets on your laptop](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md#secrets-on-your-laptop-sops)). One `SECRETS_FILE` can hold every secret, decrypted once, so a Secure Enclave key asks for Touch ID once per deploy ([#265](https://github.com/morca-dev/morca/issues/265)).

## Rescaling

```
./rescale.sh up                        # CAX21 -> CAX41, keeping the disk
./rescale.sh down                      # CAX41 -> CAX21
```

For an import, `rescale.sh` changes the server's type. It pauses every pipeline and waits for the running runs, those on remote workers too, since the UI they talk to goes down with the server; backs up, changes the type, waits for the scheduler and unpauses what it paused. The server is down for a few minutes ([Rescaling for an import](https://github.com/morca-dev/morca/blob/main/docs/hetzner.md#rescaling-for-an-import)).

## Next

- [morca #247](https://github.com/morca-dev/morca/issues/247): `orca deploy` in the CLI, with the scripts built into the binary and the settings in the app's repository.
- [morca #250](https://github.com/morca-dev/morca/issues/250): a dev stack and a release flow that promotes the image tested there to production.
- [morca #263](https://github.com/morca-dev/morca/issues/263): deploying on GCP's ARM machines beside Hetzner, and the scripts working against any host with SSH.

[morca](https://morca.dev/)

Data pipelines in Mojo. Early, and built in the open.

### Project

[GitHub organisation](https://github.com/morca-dev) [Issues](https://github.com/morca-dev/morca/issues) [morca](https://github.com/morca-dev/morca) [dista](https://github.com/morca-dev/dista) [census](https://github.com/morca-dev/census)

### Built on

[magmalake.dev](https://magmalake.dev) [The Iceberg, Parquet and codec libraries](https://magmalake.dev/tins) [mojoshelf.org](https://mojoshelf.org) [The Mojo language](https://www.modular.com/mojo)

### Standards

[Apache Iceberg](https://iceberg.apache.org) [Apache Parquet](https://parquet.apache.org) [PostgreSQL](https://www.postgresql.org)

Copyright 2026 Marius Seritan. Every repository is licensed [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0).

Apache Iceberg and Parquet are trademarks of the Apache Software Foundation. Mojo is a trademark of Modular Inc. This project is not affiliated with or endorsed by the ASF or Modular.
