First deploy, the Coolify way: a New button on every page, working URLs without DNS work and a setup checklist. Port forwarding grows up: UDP, port ranges, a dedicated IP, allow lists, several IPs per server and HTTP/3. Also: deploy from an uploaded folder, Dockerfile build arguments, container options, status badges and redirect rules.
Upgrade notes
- Nothing changes until someone turns it on. Every new setting defaults to the 0.16.0 behaviour; Traefik is not recreated by the upgrade.
- Node agents: a node keeps working on its current agent. Port mappings on a node-pinned service need the node's agent at 0.16.1; an older one is told to update when such a service deploys.
- Five additive migrations.
0078_host_ipsadds the emptyhost_ipstable;0077_port_mappingsadds the nullableservices.port_mappingsandservices.health_check;0076_domain_redirectsadds the nullabledomains.redirects;0075_status_badgeadds the nullableservices.badge_tokenand its unique index.0074_build_and_runtime_optionsadds three nullable columns,build_configs.build_args,services.health_timeout_secondsandservices.runtime_options. No row is touched, so every existing service builds and runs with exactly thedocker build/docker runline it had and keeps the 300 s healthcheck deadline. Rolling back to 0.16.0 ignores the columns. The other new settings are rows in the existingsettingstable. - Automatic URLs stay off on an upgraded panel. Without an
auto_url_modesetting nothing changes, andNINEDEPLOY_WILDCARD_DOMAINkeeps working exactly as before and wins over the new setting. Only a fresh install turns on sslip.io URLs, and only when the installer recorded a public IPv4 for the panel. - The Settings → Security "Wildcard Domain" card is gone. It showed the DNS-01 certificate apex as if it gave services URLs, which only the environment variable ever did. The apex itself is unchanged and still set in the DNS challenge form.
Added
- New resource picker (
/new). One page to start anything, like Coolify's New Resource: applications (Git repository, GitHub App, Docker image, Dockerfile, static site, and for operators Docker Compose and PM2), the 11 database engines, and the one-click app catalog, with search (/focuses it) and category tabs. The top bar, the rail and the Deploy menu all lead here; ⌘K adds "Deploy a Git repository", "Deploy a Docker image", "Deploy a Compose stack" and "Create a database".?deploy=<kind>on any page opens the wizard pre-set (git,github-app,image,compose,dockerfile,static,pm2, ornew). - Automatic URLs (Settings → Security). Every service gets its own URL when it deploys: a free
<slug>.<server-ip>.sslip.iotest URL with no DNS to set up, or<slug>.<your base domain>behind a wildcard DNS record. A node-pinned service resolves to the node's address. The names are reserved per service, so another tenant cannot claim one. API:GET/PUT /v1/settings/auto-url,POST /v1/settings/auto-url/detect-ip(asks a public echo service, only when clicked). - Generate URL on a service's Network tab (
POST /v1/services/:id/domains/auto) adds the automatic URL now instead of on the next deploy. - Getting started checklist on the Dashboard for the instance operator (
GET /v1/onboarding,PUT /v1/onboarding/dismissed): a URL for services, a Git provider, the first service and its first live deploy, then the optional own domain, HTTPS, notifications and backups. Every step is read from the panel's own state; hiding it leaves a one-line link to bring it back. - Deploy Wizard: asks for the build pack up front (Auto, Nixpacks, Railpack, Dockerfile with its path, Static with its output directory), the project and environment the service lands in, and an optional domain, which is added before the first deploy.
- Deploy from anywhere: an empty Dashboard opens with a "Deploy your first app" card (your code, a template, or the Next.js demo);
?deploy=newis a deep link to the wizard. - Dockerfile build arguments (Service → Settings → Build). Pick which of the service's variables a Dockerfile build receives as
--build-arg, like Coolify's build variables; the service's variables are offered as chips. Opt-in by name: a service gets none until you list some. The values travel as argv and are masked in every log line; a secret among them is flagged, because build arguments stay readable indocker history, and a build that pushes its image to a registry refuses one. Nixpacks and Railpack builds already saw every variable. API:build.buildArgson create, PATCH and the service response. A Dockerfile build on a remote node does not pass them yet and says so in the log. - Deploy without Git: uploaded source (like Dokploy's Drop and Railway's
up). New → Upload source takes a.zipor.tar.gzof a project folder, creates a docker service and deploys it; the service's Deploys tab takes new versions. From a terminal:ninedeploy deploy up ./my-app --create my-app(or--service <id|slug>), which packs the folder without.git,node_modules,.envand build caches. The build is the usual one: a Dockerfile wins, otherwise Nixpacks. A single top-level folder in the archive becomes the root. The last five uploads are kept, and redeploying an older deployment rebuilds the upload it came from. API:POST /v1/services/:id/source(raw body, up to 100 MiB,?deploy=falseto only store it) andGET /v1/services/:id/source; SDKservices.uploadSource/services.sourceUploads.- The archive is checked completely before it is kept: regular files and directories only (no symlinks, hardlinks or devices), relative paths without
.., at most 50 000 entries and 1 GiB extracted, no encrypted or zip64 zips. Authentication and the service checks run before the body is read. - Uploads build on the panel host; a service pinned to a remote node is refused. Deleting the service deletes its uploads.
- A docker service created with neither a repository nor an image used to fail its deploy at
git clone ''; it now says to upload its source.
- The archive is checked completely before it is kept: regular files and directories only (no symlinks, hardlinks or devices), relative paths without
- Port mappings: UDP, port ranges and a dedicated IP (Network tab → Port Mappings), like Coolify's port mappings and Dokploy's ports. A docker service publishes extra host ports straight to its container, outside Traefik: one port or a range (
27015-27030), TCP or UDP, on every address or on one IP of the host (-p 203.0.113.7:53:53/udp). Up to 20 rules and 1000 ports per service. Overlaps within the service and ports another panel-host service or a database's public access holds are refused with the owner named; ports below 1024 need an operator, and the panel, SSH, 80 and 443 stay reserved. The Host IP field offers the panel host's own addresses, and an IP the host does not have is refused when saved (GET /v1/services/:id/host-addresses). A service with host ports redeploys stop-then-start, and replicas do not publish them. A node-pinned service publishes them on its node through the agent's newdocker.portscapability (agent v0.16.1; an older agent is told to update before anything reaches the node, and the agent itself refuses SSH, 80, 443 and its own port). Swarm services are refused. CLI:ninedeploy services ports <id> [mappings...]. API:portMappingsonPATCH /v1/services/:idand the service response. - Allow lists on TCP port mappings. A single TCP port mapping can list the addresses or CIDR ranges allowed in (
5432@10.0.0.0/8,203.0.113.4,allowFromin the API). The port is then published by a small locked-down Traefik proxy on the service's bridge (nd-portproxy-<slug>) behind anipAllowList, not by Docker's-p, which a host firewall such as ufw cannot restrict. The proxy follows every deploy and is removed with the service or the last allow list. TCP single ports on the panel host only;/0is refused. - "Container running" healthcheck (Service → Settings → Healthcheck) for docker services with no HTTP or TCP port to probe, such as UDP game servers, VoIP and queue workers: the deploy waits for the container to stay up for 10 s without a restart instead of probing a port it would never reach. The Port Mappings card offers it when only UDP is published. API:
healthCheck: 'http' | 'running'. - Several IPs on one server (Settings → Security → IP addresses, operator). The host's addresses are read live; an operator labels them, marks the primary one (named in the Deploy Wizard's DNS hint and offered as the sslip.io address for automatic URLs) and dedicates one to a docker service, after which no other service may publish ports on it. The Host IP field of a port mapping shows the labels and hides addresses dedicated to other services. API:
GET /v1/host-ips,GET /v1/host-ips/primary,PUT/DELETE /v1/host-ips/:ip; SDKhostIps; CLIninedeploy ips list|set|forget. - HTTP/3 switch (Traefik → Overview, operator). Traefik always declared HTTP/3 on 443, but 443/udp was never published, so QUIC never reached it. Turning the switch on recreates Traefik with
-p 443:443/udp; a failed recreate restores the previous container and answers 502. Off by default: the container and its fingerprint are exactly 0.16.0's, so upgrading does not recreate Traefik. API:GET/PUT /v1/traefik/http3; SDKtraefik.http3. - Redirect rules per domain (Network tab → a domain's Security panel → Redirects), like Dokploy's Redirects: a regular expression over the full URL → a target (
${1}inserts the first group), temporary or permanent, up to 10 per domain, applied in order before auth, headers and rate limits. A "Moved path" preset fills in the common case. Traefik compiles them with Go RE2, so lookaround and backreferences are refused when saved, and a stored rule RE2 would refuse is skipped instead of taking the whole dynamic config down. API:redirectsonPATCH /v1/services/:id/domains/:domainIdand in the domain list. - Deploy status badge (Service → Settings → Status badge): a public SVG for a README that reads running, deploying, failed, stopped or not deployed and nothing else about the service;
?label=changes the "deploy" label. It is off until a member turns it on; the token in its URL is the only credential, can be rotated (the old URL then shows "not found") and turned off. API:GET/POST/DELETE /v1/services/:id/badge, publicGET /v1/badges/<token>.svg; SDKservices.badge. - Container options (Service → Settings, operator only): GPUs (
--gpus allor a count), shared memory (--shm-size, for browsers, Puppeteer and PyTorch), an init process (--init), the open-file limit (--ulimit nofile) and extra capabilities from a short allowlist (NET_ADMIN,NET_RAW,SYS_PTRACE,SYS_NICE,SYS_RESOURCE,IPC_LOCK,SYS_TIME). Structured fields, never free-form flags, so privileged mode, host mounts or the host network cannot be asked for, andSYS_ADMIN-class capabilities are not offered. Members see what is set and can still redeploy; a member's clone and a PR preview start without them. Applied on the panel host, in both thedocker runpath and the compose bridge; a remote node or Swarm deploy says in its log that they were not applied. API:runtimeOptionsonPATCH /v1/services/:idand the service response. - Healthcheck timeout per service (Service → Settings). A deploy waited a fixed 5 minutes for the new container; a slow first boot (migrations, model downloads) can now get up to an hour, and a quick one can fail faster. The failure message names the limit and where to raise it. PR previews keep the parent's value. API:
healthTimeoutSeconds(10–3600,null= 300). - Shared variables UI. Projects → Shared variables (
/projects?env=<id>) edits the project-wide variables every service in the project receives. The API already had them, but nothing in the panel used it. A service's Environment tab lists what it inherits, with a link to the project and an "overridden" mark where its own variable wins. - Path routing in the panel. The Network tab's domain form takes an optional path (
/api), so several services can share one host. Pastinghttps://app.example.com/apifills both fields. The API has acceptedpathall along. - SDK:
settings.autoUrl,domains.generate,onboarding.
Fixed
- Two services could publish the same direct host port. The second one was saved and its first deploy failed with Docker's "port is already allocated". Saving or creating it is now refused with the owner named (409), compared per host: the panel host, or the same node.
PATCH /v1/services/:idwith only abuildobject (no service field) answered 500 "No values to set".- A PR preview of a static-pack service lost the parent's output directory and SPA setting, so it built with the defaults.
- The add-domain form showed "Could not add the domain" for every refusal; it now shows the server's reason (taken, outside the zone, reserved).