unisrv.hcl
Reference for the declarative project file applied by unisrv up.
unisrv.hcl describes one project. unisrv up reads it from the current directory, shows a plan, and makes the environment match.
| Block | Declares | Primitive |
|---|---|---|
service | An HTTPS entrypoint and its routing table | Services |
deployment | Replicas of one container image, optionally with a volume | Deployments |
network | A private network deployments join to reach each other | Internal networks |
Example
A frontend and an API behind one domain, and Postgres on a private network with a persistent disk.
project = "webshop"
service "web" {
hosts = ["webshop.example.com"]
location "/api" {
deployment = "api"
}
deployment = "frontend" # everything else
}
deployment "frontend" {
port = 3000
container {
image = "ghcr.io/acme/webshop-frontend:${var.tag}"
}
}
deployment "api" {
port = 8000
replicas = 2
network = "internal"
memory = "1GB"
container {
image = "ghcr.io/acme/webshop-api:${var.tag}"
env = {
DATABASE_URL = "postgres://postgres:${var.db_password}@postgres:5432/postgres"
}
}
}
deployment "postgres" {
network = "internal"
container {
image = "postgres:17"
env = { POSTGRES_PASSWORD = var.db_password }
}
volume {
mount_path = "/var/lib/postgresql/data"
size = "10GB"
}
}
network "internal" {}unisrv up --var tag=v1.4.0 --var-file secrets.envFile rules
projectis required and must be a plain string. No variables.- Block labels are lowercase letters, digits and hyphens, 1 to 48 characters, with no hyphen at either end. Each label is unique per block type.
- Unknown attributes are errors.
replica = 2fails instead of being ignored.
service
service "web" {
hosts = ["webshop.example.com"]
allow_http = false
location "/api" {
deployment = "api"
}
deployment = "frontend"
}| Attribute | Type | Default | Notes |
|---|---|---|---|
hosts | list of strings | [] | Your own domains. Each must be claimed first and belongs to one service. |
allow_http | bool | false | When false, plain HTTP redirects to HTTPS. |
deployment | string | Shorthand for a catch-all location "/". Can't be combined with an explicit "/". | |
location | block | One per path prefix. See below. |
Every service is also reachable at {service}-{slug}.unisrv.dev, using its environment's slug.
Hosts. Matched case-insensitively, with any trailing dot dropped. Under unisrv.dev a host must be a single label of lowercase letters and digits: webshop.unisrv.dev works, my-webshop.unisrv.dev and a.b.unisrv.dev don't. Other domains need DNS pointed at the Unisrv edge before they're claimed.
location
The label is a path prefix. The longest prefix that matches wins, so the order you write locations in doesn't matter.
Each location sets exactly one target:
| Target | Routes to | Request path |
|---|---|---|
deployment | A deployment in this file. It must set port. | Forwarded unchanged |
url | An absolute http:// or https:// URL | Prefix stripped, the rest appended to the URL's path |
instance_group | Instances you start through the API with that group name, not managed by this file | Forwarded unchanged |
Optionally, override_404 serves another path from the same target when it returns 404. Use "/index.html" for single-page apps. It must be a path, not a URL.
location "/" {
deployment = "frontend"
override_404 = "/index.html"
}
# /legacy/users?page=2 → https://old.example.com/users?page=2
location "/legacy" {
url = "https://old.example.com"
}Path rules:
- Starts with
/. No?,#, whitespace or//. - Prefixes match as raw strings:
/apimatches/api,/api/usersand/apiv2. Use/api/to match only the subtree.
deployment
deployment "api" {
port = 8000
replicas = 2
network = "internal"
vcpus = 1
vcpu_ratio = 0.25
memory = "1GB"
container {
image = "ghcr.io/acme/webshop-api:${var.tag}"
args = ["--workers", "4"]
env = { LOG_LEVEL = "info" }
}
}| Attribute | Type | Default | Range | Notes |
|---|---|---|---|---|
container | block | Required. See below. | ||
port | number | 1 to 65535 | Port the container listens on. Required if a location routes here. | |
replicas | number | 1 | 0 to 10 | 0 keeps the deployment but runs nothing. |
network | string | A network block in this file. | ||
vcpus | number | 1 | 1 to 32 | Per instance. |
vcpu_ratio | number | 0.25 | 0.125, 0.25, 0.5, 1.0 | Guaranteed share of a physical core per vCPU. 1.0 is dedicated. |
memory | number or string | 512 | 128MB to 32GB | Per instance. See sizes. |
volume | block | A persistent disk per replica. See below. |
A deployment can be routed by only one service, from any number of its locations.
container
| Attribute | Type | Required | Notes |
|---|---|---|---|
image | string | yes | A pushed OCI image. There is no build step. Private registries need unisrv registry add. |
args | list of strings | no | Replaces the image's CMD. |
env | map of strings | no | Environment variables. Pass secrets through variables. |
A moving tag like :latest is only pulled when instances are replaced. After re-pushing one, run unisrv up --invalidate. Immutable tags avoid the problem.
The container's own filesystem is thrown away whenever an instance is replaced. Only a volume survives.
volume
volume {
mount_path = "/var/lib/postgresql/data"
size = "10GB"
}| Attribute | Type | Required | Range | Notes |
|---|---|---|---|---|
mount_path | string | yes | Absolute path in the container. Not /, no ... | |
size | number or string | yes | 32MB to 256GB | Per replica. See sizes. |
The block is a template. Each replica gets its own volume, numbered from 0, and gets the same one back after restarts, rollouts and scaling. replicas = 3 with size = "10GB" means three 10GB disks.
- Rollouts stop the old instance before starting the new one, since a volume has one writer. Expect a short gap.
- A volume stays on the node it was first written to, and so does its replica.
- Scaling down keeps the unused volumes and gives them back when you scale up.
The volume is fixed at creation. Adding, removing or changing the block replaces the deployment, and the new one
starts with empty volumes. The old ones are kept in the environment with their data but never reattached. See what
unisrv up replaces.
Sizes
memory and size take a bare number of megabytes, or a string with M, MB, G or GB (any case). Units are binary: "1GB" is 1024MB. Fractions are fine if they land on a whole MB: "1.5GB" works.
network
network "internal" {
iprange = "10.0.0.0/16"
}| Attribute | Type | Default | Notes |
|---|---|---|---|
iprange | string | "10.0.0.0/16" | IPv4 CIDR. Host bits must be zero: 10.0.0.5/16 is rejected. |
Deployments on the same network reach each other by name. postgres resolves to the IPs of the postgres deployment's running instances, one per replica. There are no ports to declare: connect on whatever port the target listens on.
A deployment without a network can't resolve other deployments or be resolved.
Variables
Any string except project can use variables: "${var.name}" inside a string, or var.name as the whole value. Values are always strings.
image = "ghcr.io/acme/api:${var.tag}"
env = { API_KEY = var.api_key }Supply them when you run unisrv up:
| Source | Format |
|---|---|
--var name=value | Repeatable. |
--var-file FILE | Repeatable. name=value lines, # comments. |
| Prompt | Asked for any unset variable. Without a terminal, an unset variable is an error. |
- There is no override order. Setting a name twice, in any source, is an error.
- Unused variables are ignored.
Keep secrets in a var file outside version control.
What unisrv up changes
Most edits update resources in place. A few can only be applied by deleting and recreating.
| Change | Result |
|---|---|
Image, env, args, resources, replicas, port | Rolling update |
A deployment's network | Rolling update onto the new network |
Hosts, locations, allow_http | Updated in place |
| Moving a deployment to another service | Deployment replaced¹ |
Adding, removing or changing a volume | Deployment replaced¹ |
A network's iprange | Network replaced, and every deployment on it¹ |
| Renaming any block | Old one deleted, new one created |
¹ A replaced deployment starts on empty volumes. The old ones stay in the environment with their data.
A private image without a registry credential passes the plan but fails when its instances start.
Confirmations:
- Every plan asks before applying.
-yanswers yes. It's required when there's no terminal, as in CI. - A plan that leaves volumes behind lists them and asks you to type
destroy.-ydoesn't answer this.--allow-data-lossdoes, so an unattended run needs both. - Removing a
deploymentblock doesn't ask. Its volumes stay in the environment with their data, and their cost, untilunisrv destroy.