Solo Laptop or Mac mini

A single-host setup. The most common starting point: you want to learn the tool, run side projects, or stage what you'll later split across machines — all on the laptop you already use.

TL;DR

Hardware 1 machine — laptop, Mac mini, NUC, or any always-on workstation
OS macOS 12+ or any Linux with bash 4.0+, yq, jq, ssh, curl
RAM 8 GB minimum for the demo + a handful of services. 16–32 GB if you want to host real workloads.
Time to first deploy ~5 minutes (demo via root docker-compose.yml)
Biggest gotcha (macOS) Apple ships Bash 3.2. Install Bash 5 with brew install bash before running the CLI.

Why pick this shape

  • Zero networking surprises. localhost is your cluster.
  • All three deployment types work side by side — docker containers, local Python via uv, and native services under launchd or systemd.
  • The same registry.yml you write here keeps working when you grow into a 3+ machine cluster — you only add new entries to hosts:.

What ships out of the box

The repo's root docker-compose.yml is a "Path 1" demo: Caddy on :8080, a tiny FastAPI dashboard, and one nginx-backed dummy service. It does not start Postgres, Redis, Vault, or Keycloak.

git clone https://github.com/nonagenticai/portoser.git
cd portoser
cp .env.example .env
docker compose up
# Open http://localhost:8080

The demo registry is at demo/registry.demo.yml — useful as a reading sample, not as a base for your own cluster.

Wiring up your own services

Once the demo is up, point CADDY_REGISTRY_PATH at a real registry. Minimal example for a single host:

domain: internal
hosts:
  laptop:
    ip: 127.0.0.1
    arch: arm64-apple        # or amd64-linux on x86 Linux
    ssh_user: <your-user>
    path: <repo-base-path>
    roles:
      - everything
services:
  notes-api:
    hostname: notes.internal
    current_host: laptop
    deployment_type: docker
    docker_compose: /notes-api/docker-compose.yml
    port: 8400
    healthcheck_url: http://notes.internal/health
  notes-worker:
    hostname: notes-worker.internal
    current_host: laptop
    deployment_type: local
    service_file: /notes_worker/service.yml
    port: 9001
caddy:
  host: laptop
  ingress_host: laptop
  config_path: /caddy/Caddyfile
  admin_api: http://127.0.0.1:2019
  use_admin_api: true
version: 3.0
schema: minimal

Two services on one host, one Docker-based and one Python-uv-based. SSH still happens (Portoser uses SSH even when the target is localhost), so make sure passwordless SSH to your own machine works:

ssh-copy-id $USER@127.0.0.1
ssh $USER@127.0.0.1 "echo ok"

Bringing it up

# Validate the registry
./portoser registry validate

# Deploy a single service
./portoser deploy laptop notes-api

# Deploy multiple at once
./portoser deploy laptop notes-api notes-worker

# Health check
./portoser health check-all

The web UI is its own service, brought up via web/docker-compose.yml:

cd web
docker compose up -d
# Open http://localhost:8989 (frontend) — backend on 8988

Health and observability on a single host

  • ./portoser health check-all prints a JSON-shaped per-service health summary.
  • ./portoser metrics taps the metrics collector for CPU / memory / disk per service.
  • ./portoser uptime shows uptime windows tracked by the uptime collector.
  • The web UI's monitoring dashboard renders the same data live over WebSocket.

Native services on a single host

native deployments work even without a multi-machine setup. On macOS, this means launchctl; on Linux, systemctl. Useful for things you want managed by the OS rather than Docker — Postgres, dnsmasq, your own daemons.

services:
  postgres-local:
    hostname: postgres.internal
    current_host: laptop
    deployment_type: native
    service_file: /postgres/service.yml
    port: 5432

The service_file points at a YAML descriptor inside your service's repo telling Portoser how to start, stop, and health-check it. lib/native.sh reads that file and dispatches to the right launchctl / systemctl invocation.

Common gotchas

  • bash: bad substitution on macOS. You're on Apple's Bash 3.2. Run brew install bash and put Homebrew's Bash on your PATH.
  • Port already in use. The self-healing loop's port_conflict pattern catches these on deploy. If you want to inspect manually: lsof -nP -iTCP:<port> -sTCP:LISTEN.
  • yq flavor mismatch. Portoser uses Mike Farah's Go-based yq (v4), not the Python yq. brew install yq gives you the right one.
  • Caddy can't reload. If caddy.use_admin_api: true, Caddy must be running. For a demo, the docker-compose Caddy is on :8080. For real deployment of services on the host, install Caddy via brew install caddy and let ./portoser caddy sync write the Caddyfile for you.

Where this shape falls down

  • A single host means no failover. If your laptop sleeps, your cluster sleeps.
  • mTLS still works, but the threat model is mostly about staging the configuration that will matter later when you add a second host.
  • You're paying SSH and docker exec overhead even though everything is local. It's fine for tens of services. Past that, consider splitting roles across at least two hosts.

Next