# Deploy to a VM

> Ship a container to a Linux VM instead of a Kubernetes cluster; same pipeline, Docker + Caddy substrate, TLS and multi-service routing handled for you.

Source: https://zop.dev/docs/zopday/deploy/vm-substrate

---

Not every workload wants Kubernetes. ZopDay can ship a container straight to a **Linux VM** as an alternative substrate; same catalog, same revisions, same live progress; but the target is a Docker host with Caddy in front instead of a cluster. You still hand over no kubeconfigs, no SSH keys, and no standing credentials.

![Provision Infrastructure drawer with Virtual Machine selected: "EC2, Compute Engine, or Azure VM, provisioner sets up VPC + IAM + security groups; Docker and Caddy install automatically on first deploy", with cloud account, region, name/size, and VPC options below](https://storage.googleapis.com/zopdev-blog-resources/1/files/originals/20260707/69248b2f-faee-47d7-a51b-c6b798e3affb-provision.png)

*The Virtual Machine substrate; ZopDay sets up the VM and installs Docker + Caddy automatically, so the first deploy has a container host and TLS router ready.*

## When a VM is the right target

- A single service (or a handful) that doesn't need cluster orchestration
- A cost-sensitive environment where a full cluster is overkill
- A legacy or simple app you want live with TLS in minutes

For orchestration-heavy, multi-service, autoscaling workloads, deploy to a [cluster](https://zop.dev/docs/zopday/concepts/deployment-spaces) instead.

## How it differs from a cluster deploy

A VM substrate is a deployment space with `space_type = vm`. The **pipeline is identical**: the same claim → build (GitHub Actions) → deploy flow, only the final executor arm changes: it runs a `runcommand` (docker pull/run + a Caddy route update) instead of a `helm` release.

| | Cluster substrate | VM substrate |
|---|---|---|
| Runtime | Helm release on Kubernetes | Docker container on a Linux VM |
| Router / TLS | Ingress controller + cert-manager | **Caddy** (Let's Encrypt via ACME) on the VM |
| Exec channel | kubeauth (ephemeral kubeconfig) | IAM-gated exec (SSM / IAP / Azure Run Command), **no public SSH** |
| Multi-tenant routing | Namespaces | Per-service Caddy site blocks on a shared host |

## Baseline components

Every VM in a substrate has two idempotent components installed: **Docker** and **Caddy**. Caddy's bootstrap config includes a `:80` health responder so the cloud load balancer reports healthy from the moment install finishes; there's no unhealthy window before the first deploy.

Caddy is the router when a VM is used as a ZopDay deploy substrate; a VM provisioned as standalone infrastructure uses NGINX instead (see [Provisioning VMs](https://zop.dev/docs/zopday/provisioning/vms)).

## Scale to zero

Every service on a VM otherwise runs around the clock, which on a shared VM means a handful of idle services holding memory that the busy ones could use. **Scale to zero** is an optional third component that stops a service's container after a period with no traffic and starts it again on the next request, showing the visitor a wake page while it comes up.

- **Enabled per space, not per service.** One install covers the services already on the VM and every service deployed to it afterwards. There are no per-service switches.
- **The idle window is a space setting**, 30 minutes by default.
- **Private services are excluded automatically.** A service with no public route has nothing to wake it, so it is left running.

**Warning**

**Turn Network off on databases before enabling this, or exclude them.** A datastore deployed with Network on gets a hostname and a public route like any other service, which means scale-to-zero will put it to sleep. A database is reached on its own protocol port, not over HTTP, so nothing will ever wake it back up. This has been seen in practice on a Postgres running on the shared pool.

**Note**

There is no uninstall path today: once scale-to-zero is installed on a space, the wake service becomes a dependency of those routes. Enable it on a space where that is acceptable.

## TLS and hostnames

- **TLS terminates at Caddy** via Let's Encrypt (HTTP-01 on :80, TLS-ALPN-01 on :443). No cert wrangling.
- **Automatic hostname**: if a deployment has no explicit expose config but its space carries wildcard domain patterns, ZopDay synthesises a stable hostname of the form `<name>-<short-id>.<space-suffix>`. The short-id suffix prevents same-name collisions across deployments. The Quick Deploy "Live at" preview shows exactly the host Caddy will serve.

## Multiple services on one VM

Several deployments can share one VM. Each deploy adds its own Caddy site block **without touching co-resident services**; their routes and containers are preserved across your deploy. Teardown is symmetric: removing one service leaves the others running. ZopDay fails a deploy that would collide on host **or** port rather than silently overwriting.

## No public SSH

ZopDay never opens a public SSH session to your VM. The deploy runs over the cloud's IAM-gated exec channel: `aws ssm send-command`, `gcloud compute ssh --tunnel-through-iap`, or `az vm run-command`, dispatched from the GitHub Actions runner, not from a ZopDay pod. Port 22 is not publicly reachable; interactive access is only through that IAM-gated channel.

## Private registries

For images in a private registry (GAR / ECR / ACR), ZopDay mints a short-lived registry token server-side and injects a `docker login` before the pull, so the VM never holds long-lived cloud credentials.

## Reliability

If the build's success callback can't reach ZopDay, a watchdog scrapes the build log for the canonical "successfully pushed" marker and recovers the deploy, covering both the callback-failed-after-success and the failure-but-push-completed cases.

**Note**

Live application logs and live resource metrics for the VM substrate are on the roadmap; they'll land with the VM logs/metrics UI. Cluster substrates have live logs today. In the meantime, use the IAM-gated exec channel (`aws ssm start-session`, `gcloud compute ssh --tunnel-through-iap`, or `az vm run-command`) to reach the VM and inspect containers directly (for example `docker ps` and `docker logs`).
