unisrv.hcl
Reference for the declarative project file applied by unisrv up.
unisrv.hcl declares the desired state of one project: HTTP services (routing and domains), deployments (containers), and internal networks (private connectivity). It lives in the directory where you run unisrv up.
project = "myapp"
service "web" {
deployment = "web"
}
deployment "web" {
port = 8080
container {
image = "nginx:1.27"
}
}Top level
| Element | Form | Required |
|---|---|---|
project | project = "name" | yes |
service | service "<name>" { ... } | no, repeatable |
deployment | deployment "<name>" { ... } | no, repeatable |
network | network "<name>" { ... } | no, repeatable |
project must be a literal string — no interpolation. Block labels are unique per block type and must be DNS-safe: lowercase letters, digits and hyphens, no leading or trailing hyphen, 1–48 characters (^[a-z0-9]([a-z0-9-]*[a-z0-9])?$).
service
An HTTPS entrypoint that routes by path prefix to deployments or external URLs.
service "web" {
hosts = ["myapp.unisrv.dev", "www.example.com"]
allow_http = false
location "/api" {
deployment = "api"
}
# Shorthand: catch-all "/" route, appended AFTER all explicit locations.
deployment = "frontend"
}| Attribute | Type | Default | Notes |
|---|---|---|---|
hosts | list of strings | [] | Domains, claimed with unisrv host claim before unisrv up. Each binds to one service globally. |
allow_http | bool | false | When false, plain HTTP redirects to HTTPS. |
deployment | string | — | Sugar for location "/" { ... }, appended after explicit locations so it never shadows them. Cannot be combined with an explicit location "/". |
location | block, repeatable | — | Routing table, see below. |
Host rules:
unisrv.devdomains must be a single alphanumeric label:myapp.unisrv.devis valid,my-app.unisrv.devanda.b.unisrv.devare not.- External domains may contain hyphens freely, and need DNS pointed at the Unisrv edge.
- Every service is always reachable at
{service-name}-{environment-slug}.unisrv.devwithout being declared, sohostsare only needed for a permanent address of your own.
location
location "/api" {
deployment = "api"
}
location "/" {
deployment = "frontend"
override_404 = "/index.html" # SPA fallback
}
location "/legacy" {
url = "https://old.example.com/app"
}The label is a path prefix, matched in declaration order, first match wins. Exactly one target must be set:
| Attribute | Meaning |
|---|---|
deployment | Route to that deployment and bind it to this service. It must define port. |
instance_group | Escape hatch: route to a raw instance group, for instances managed out-of-band. |
url | Reverse-proxy to an absolute http(s):// URL. |
Optional override_404: a path (query allowed) on the same upstream to serve when it returns 404, typically "/index.html". Must be a path, not a full URL.
Path rules:
- Must start with
/; no?,#, whitespace, or//. - No trailing
/except the root"/". Prefixes match as raw strings, so/apialready matches/api/users. - No duplicates, and a path that extends an earlier one is unreachable and rejected — order most specific first (
/api/adminbefore/api).
deployment
N replica instances of one container image.
deployment "api" {
port = 8000
replicas = 2
network = "internal"
vcpus = 1
vcpu_ratio = 0.25
memory = "1GB"
container {
image = "registry.example.com/myapp/api:${var.image_tag}"
args = ["--workers", "4"]
env = {
DATABASE_URL = "postgres://app:${var.db_password}@postgres:5432/app"
LOG_LEVEL = "info"
}
}
}| Attribute | Type | Default | Range | Notes |
|---|---|---|---|---|
port | number | — | 1–65535 | The port the container listens on. Required if a location routes here; omit for workers. |
replicas | number | 1 | 0–10 | 0 keeps the deployment defined but runs nothing. |
network | string | — | — | A network block to join. Omit if it needs no private connectivity. |
vcpus | number | 1 | 1–32 | vCPUs 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, lower is burstable. |
memory | number or string | 512 (MB) | 128MB–32GB | Bare number = MB. Strings take binary units, case-insensitive: "2GB", "2g". Fractions must land on a whole MB. |
container | block | — | — | Required. See below. |
container
| Attribute | Type | Required | Notes |
|---|---|---|---|
image | string | yes | Full OCI reference, already built and pushed — there is no build-from-source. Private registries need unisrv registry add. Prefer immutable tags or digests over latest. |
args | list of strings | no | Overrides the image CMD. |
env | map of strings | no | Environment variables. Use ${var.NAME} for secrets. |
A deployment can be routed from at most one service (any number of its locations). Two services routing the same deployment is an error.
Storage is ephemeral. Scratch-only filesystems, no persistent volumes; anything written is lost when an instance is replaced by a redeploy, scale, or failure. Suitable for a dev or preview database, not durable data — use an external provider.
network
Private connectivity and name-based discovery between deployments.
network "internal" {
iprange = "10.0.0.0/16"
}| Attribute | Type | Default | Notes |
|---|---|---|---|
iprange | string | "10.0.0.0/16" | IPv4 CIDR, /16 or /24. Host bits must be zero (10.0.0.5/16 is rejected). |
Service discovery
Within one network, a deployment's name resolves to the IPs of its running instances. No ports are involved: connect using the deployment name and whatever port the target listens on.
network "internal" {}
deployment "postgres" {
network = "internal"
container {
image = "postgres:17"
env = { POSTGRES_PASSWORD = "${var.db_password}" }
}
}
deployment "api" {
port = 8000
network = "internal" # must be the SAME network as postgres
container {
image = "myapp/api:${var.image_tag}"
env = { DATABASE_URL = "postgres://postgres:${var.db_password}@postgres:5432/postgres" }
}
}postgres needs no port and no service — those are for inbound HTTP only. A deployment without a network can neither resolve nor be resolved, and with replicas > 1 the name maps to all running instance IPs.
Variables
Any string except project may interpolate variables: "${var.NAME}" inside a template, or bare var.NAME as a whole value. All values are strings.
deployment "api" {
container {
image = "ghcr.io/myuser/api:${var.image_tag}"
env = {
API_KEY = var.api_key
}
}
}Values are supplied at unisrv up time, never stored in the file:
--var NAME=value(repeatable)--var-file FILE(repeatable; dotenv format:NAME=valuelines,#comments)- An interactive prompt for any referenced-but-unset variable
Names are identifiers: a letter or _, then letters, digits, underscores. Every supplied variable must be referenced, and no name may be supplied twice — both are errors.
Use variables for all secrets and for image tags that change per release, and keep the var-file out of version control.
Before it applies
A valid file still fails at apply time if out-of-band setup is missing: every domain in hosts must be claimed with unisrv host claim, and private images need a credential from unisrv registry add. unisrv up shows a plan and asks before applying, including for destructive changes (renaming a service or network, changing an iprange, moving a deployment between services).