Guides

Build and deploy an app, then inspect and operate your server.

Getting started with Solo

This guide deploys a static site to one Ubuntu 22.04 LTS server. It uses an explicit command builder because the generated dockerfile builder is currently unfinished. Live qualification on 2026-11-08 confirmed Ubuntu/password SSH and Tailscale administration, but stock initialization failed because the pinned jsmp/solo-proxy:v0.2.0 image was unavailable. Both static-site tarball delivery and native Swift managed-registry delivery were exercised with a source-built proxy. Stock initialization remains blocked until the pinned image is published; evidence and the local-image workaround are recorded in the repository at qualification/README.md.

Prerequisites

  • Solo installed and available as solo on your PATH. For a source build, follow the installation steps in the repository README.
  • Podman with a running machine for the example below, or Docker with a running daemon after substituting docker for podman in the build command. Check solo info.
  • A Git repository with committed application files. Keep source and build identity clear; do not assume every builder excludes working-tree changes.
  • A fresh Ubuntu 22.04 LTS amd64 server with root password access over SSH. This example deliberately uses one architecture; mixed-architecture build metadata is not complete.
  • For HTTPS, a domain resolving to the server and inbound TCP 80/443. For local HTTP-only testing, follow the separate section below.
  • A working secret store: macOS Keychain, or configured gopass where available. The package declares macOS 14 as its host platform; Linux CLI portability is not established by this guide.
solo version
solo info

1. Create the project

mkdir hello-solo
cd hello-solo
git init
mkdir html
printf '<h1>Hello from Solo</h1>\n' > html/index.html
solo init --interactive

For an html/index.html project without a Dockerfile, Solo can generate an Nginx Dockerfile. You can also write it explicitly:

FROM nginx:alpine
COPY html/ /usr/share/nginx/html
EXPOSE 80

Replace the generated configuration with the following. Substitute your server IP and domain, then create an A record for hello.example.com pointing to the server.

project: hello-solo
servers:
  - host: 203.0.113.10
    ssh_user: root
    ssh_port: 22
    domain: hello.example.com

services:
  - name: hello
    port: 80
    replicas: 1
    builder:
      type: command
      command: |
        image="localhost/hello:$(date -u +%Y%m%d%H%M%S)"
        podman build --platform linux/amd64 -t "$image" . >&2 || exit 1
        printf '%s\n' "$image"
    proxy:
      host: hello.example.com
      healthcheck:
        path: /

The command builder's final nonempty stdout line must be the image reference. This command builds the working directory; commit all intended files before building. For Docker, replace podman build with docker build and use the same engine for local image delivery.

For this non-Swift builder, omitting registry selects tarball delivery. The managed project-local registry is currently specific to native Swift container builds.

git add Dockerfile html solo.yml
git commit -m "Configure Solo static site"

2. Initialize the server

solo server init
solo server ls
solo proxy
solo proxy tls

Solo validates the OS/architecture, installs Docker and solo-proxy, and configures UFW/fail2ban. The proxy occupies ports 80/443. Initialization waits for the server domain's HTTPS certificate and fails if HTTPS is unavailable. It does not install Swarm services or deploy your application.

Keep servers[].host as the public server identity. If you later migrate administration to Tailscale, Solo sets ssh_host separately; see private administration.

3. Build and inspect the artifact

solo build create hello
solo build ls

The local state records build identity, source commit when available, source state, image reference, and build time. Clean builds use the commit as identity. Dirty builds are labeled dirty-<timestamp> or dev-<timestamp> rather than being represented as clean commits. Image tags and deployment release labels are separate identifiers.

Builder source behavior differs: Dockerfile archive builds use committed HEAD; native Swift builds use a copied working directory; command/script builders decide for themselves. The example above builds the working directory.

4. Deploy the recorded build

solo deploy --app hello

solo app deploy --app hello is the same command. Review the selected build and delivery mode. Without a registry, confirm the compressed image tarball transfer. Solo uploads/loads the image, starts app containers, checks / for HTTP 200, switches proxy traffic, drains old requests, and observes the new backend.

curl -I https://hello.example.com/
solo ps
solo app journal --app hello

solo deploy --app hello --last-build skips build-selection confirmation, but other setup/delivery decisions may still prompt. --force skips watchdog observation; it does not build new source. Deployment JSON includes per-server results; configure credentials beforehand for automation.

See the captured solo ps example for a real three-container deployment and the fields it reports.

Daily operations

solo logs --app hello --no-follow
solo proxy log general --no-follow
solo proxy tls
solo stats
solo reconcile --dryrun
solo app pause --app hello
solo app resume --app hello
solo app scale --app hello 2

Scaling changes live replicas; update services[].replicas when you want that count to remain the desired configuration for later deploys/reconciliation. solo app stop stops traffic and keeps containers running. It is not a Docker container stop command.

Install monitoring explicitly with solo proxy install (it is included by default unless --no-monitoring is passed). Server initialization and app deployment do not install it. Prometheus and Grafana are private on server loopback; use an SSH tunnel or private Tailscale Serve. Grafana initially uses admin/admin; change it on first login. See architecture.

Swift package container builds

For a package already using swift-container-plugin, solo init can select the native builder instead of creating a Dockerfile:

services:
  - name: api
    port: 8080
    builder:
      type: swiftPackageContainer
      sdk: x86_64-swift-linux-musl
      product: MyServer
      swiftCommand: swift
    proxy:
      host: api.example.com
      healthcheck:
        path: /up

Keep the server configuration and set its domain to example.com or api.example.com as appropriate. Install the Static Linux SDK matching your Swift toolchain. Use aarch64-swift-linux-musl for an ARM target, while accounting for the current architecture-metadata limitation. If needed, set swiftCommand: swiftly run -- swift to use the matching toolchain.

Without a remote registry, Solo builds into .solo/registry from .solo/scratch/<service> and opens a temporary SSH tunnel for server pulls. This build path can avoid a local Docker daemon; servers still run Docker. The app must return HTTP 200 on its configured health path.

Remote registries

Use registry-qualified image names appropriate for your registry:

registry:
  url: https://registry-1.docker.io
  user: your-user

A command/script builder must produce the correct registry-qualified image reference and leave it accessible to the publishing tool. After building, publish/prefetch explicitly where needed:

solo build push hello
solo deploy --app hello

build push does not run the service. It is unnecessary for the tarball quickstart and for the native Swift managed-registry delivery flow. Do not assume configuring a remote registry publishes an existing local artifact automatically.

Intentional HTTP-only testing

Omit servers[].domain, remove the service's explicit proxy.host, set proxy.ssl: false, and initialize with:

solo server init --http-only
solo build create hello
solo deploy --app hello
curl http://203.0.113.10/

Keep proxy.healthcheck.path: / for Nginx. This uses the catch-all route; use only one catch-all service per server. Later proxy install/upgrade commands also need --http-only. Do not use an HTTP-only example as the public HTTPS setup.

Troubleshooting

  • Unsupported OS: use Ubuntu 22.04 LTS; solo version states the current policy.
  • Proxy initialization fails: check server domain, DNS, inbound 80/443, and solo proxy log general --no-follow.
  • Readiness fails: return 200, not 204/301, at the configured path and port. Nginx needs /; most apps default to /up.
  • No usable Dockerfile build artifact: use the explicit command builder above; the native Dockerfile implementation is incomplete.
  • Unknown commands: use the command reference. There is no solo app logs, solo app restart, or solo app rollback command.
  • Recovery: automatic watchdog rollback only exists during its window; historical release rollback is unfinished. Inspect solo proxy --json, the journal, and the proxy guide.
  • Multiple servers with one HTTPS domain: configure and validate the cluster coordination workflow; DNS round-robin alone does not provide certificate coordination or health-aware failover.

Static site guide

Serve an html/ directory with Nginx behind solo-proxy. The proxy owns public ports 80/443; Nginx stays on Solo's Docker network. Do not start a second container with -p 80:80 on the same server.

Image and readiness

my-site/
  Dockerfile
  solo.yml
  html/
    index.html
FROM nginx:alpine
COPY html/ /usr/share/nginx/html
EXPOSE 80

solo init --interactive detects html/index.html and can scaffold the Dockerfile/configuration. Review the generated files and replace the unfinished default Dockerfile builder with an explicit command builder as shown below.

Configure

The example targets an amd64 Ubuntu 22.04 LTS server. Set up DNS before initialization.

project: static-site
servers:
  - host: 203.0.113.10
    ssh_user: root
    domain: site.example.com

services:
  - name: site
    port: 80
    replicas: 1
    builder:
      type: command
      command: |
        image="localhost/site:$(date -u +%Y%m%d%H%M%S)"
        podman build --platform linux/amd64 -t "$image" . >&2 || exit 1
        printf '%s\n' "$image"
    proxy:
      host: site.example.com
      healthcheck:
        path: /

Use docker build instead if Docker is your local engine. Commit the source/configuration before building. This command builds the working directory and returns the image reference on stdout. The default /up health path does not exist in a standard Nginx image; / must return HTTP 200.

Build and deploy

git add Dockerfile html solo.yml
git commit -m "Configure static site deployment"
solo server init
solo build create site
solo deploy --app site
curl -I https://site.example.com/
solo logs --app site --no-follow

With no remote registry, confirm the SSH tarball delivery. You do not need a separate solo build push step. The proxy checks readiness and handles traffic switching and draining. See deployment lifecycle for watchdog and rollback boundaries.

For HTTP-only testing, omit the server domain and explicit proxy host, set proxy.ssl: false, and use solo server init --http-only; keep the / health path. See the full quickstart.

Static site generators

For Hugo, Jekyll, or another generator, build static output before packaging it or in a multi-stage Dockerfile. The final image should copy generated files into /usr/share/nginx/html and serve them on port 80. Choose a builder image/version appropriate for your project and commit its dependency lockfiles.

If output is generated locally, the explicit command builder can run your existing build script and then package the output. A script builder has the same final-stdout-line contract:

builder:
  type: custom
  script: scripts/build-site-image.sh

Your script must fail on build errors and print the final image reference only after a successful build. Dockerfile archive builds use committed HEAD content, whereas command/script builders control their own source inclusion.

Nginx customization

A custom configuration can provide SPA fallback, cache headers, or a dedicated readiness location. Keep readiness independent of optional external services when appropriate. Mount configuration at image-build time or via service volumes; do not assume volume data is replicated or backed up by Solo.

For custom Nginx configuration, add:

COPY nginx.conf /etc/nginx/conf.d/default.conf

For runtime status and drift, use solo ps, solo proxy, solo app journal --app site, and solo reconcile --dryrun.

Vultr demo

Use a fresh Ubuntu 22.04 LTS amd64 instance to demonstrate Solo's single-server workflow. This guide uses an explicit command builder and SSH tarball delivery, avoiding the unfinished native Dockerfile build path and the need for a registry account.

Prepare

Create an instance in your Vultr account, record its public IP/root password, and allow SSH plus HTTP/HTTPS through any provider firewall. Size it for the app and optional monitoring stack; this guide does not prescribe a current provider plan or price.

Locally, build/install Solo and start Podman (or Docker). Create the project/configuration from the static site guide, replacing 203.0.113.10 with the instance IP. Use a temporary domain resolving to that IP for HTTPS.

HTTPS demo

With servers[].domain and services[].proxy.host set to the demo domain, run:

solo version
solo server init
solo server ls
solo build create site
solo build ls
solo deploy --app site
solo ps
solo proxy tls
solo app journal --app site

Open https://<your-demo-domain>/. Explain the distinct steps: server setup, local artifact build, SSH delivery, readiness-gated routing, and observation. Do not claim the default Dockerfile builder or historical release rollback is complete.

HTTP-only demo

For a domain-free test, omit the server domain and explicit proxy host, set proxy.ssl: false, and keep the Nginx health path /:

solo server init --http-only
solo build create site
solo deploy --app site
curl http://<server-ip>/

Normal server init without a domain is not the HTTP-only path. Later proxy upgrades also require --http-only for this configuration.

Show an update

Edit html/index.html, commit it, build again, and deploy:

git add html/index.html
git commit -m "Update demo page"
solo build create site
solo deploy --app site
solo logs --app site --no-follow
solo reconcile --dryrun

This is a demonstration recipe, not a live-server validation result from the documentation update. The watchdog's previous-backend rollback window is separate from historical release recovery, which is still a gap.

Troubleshooting and cleanup

Check solo proxy log general --no-follow for initialization/TLS problems. Verify DNS and inbound 80/443 before retrying HTTPS setup. For readiness failures, confirm Nginx returns 200 on / and the configured service port is 80.

After the demo, delete the temporary instance and DNS records through your provider. Inspect solo secret ls for stored credentials and handle their removal in the configured secret store; do not imply there is an unimplemented Solo secret-delete command. Preserve state if you need the deployment journal/build history.

Firewall and private administration

solo server init configures UFW and fail2ban for a fresh Ubuntu 22.04 LTS server. Public ingress is HTTP/HTTPS; SSH is the administration transport. Use a provider firewall that permits the intended ports as well.

Public and private endpoints

EndpointStandard exposure
HTTP / HTTPSPublic TCP 80 / 443 on solo-proxy
SSHConfigured SSH port; can be migrated to Tailscale
Proxy managementPrivate Unix socket, accessed through SSH
Proxy metricsPort 9091 inside the Docker network
PrometheusServer loopback 127.0.0.1:9090
GrafanaServer loopback 127.0.0.1:3000; optional private Tailscale Serve
Service expose portsExplicit additional bindings; inspect both Docker and firewall configuration

Docker-published ports and UFW rules are distinct controls. Do not assume that adding a container port has configured its desired firewall policy. solo reconcile --dryrun can suggest selected missing allow rules; inspect the plan before applying it.

Move SSH to Tailscale

Connect Tailscale on your local machine, configure the public server in solo.yml, then run:

solo server tailnet <public-host> --auth-key-file /path/to/tailscale-auth-key

Omit the auth-key file if the server is already joined. The command installs Tailscale on the server if necessary, enables/starts tailscaled, joins the tailnet, allows SSH on tailscale0, verifies a fresh SSH connection to its Tailscale IPv4 address, persists ssh_host, ssh_transport: tailscale, and the administration address/transport in state, then removes the generic public SSH rule. Other public SSH rules cause the command to fail for operator review.

You can migrate administration before solo server init on a server with active UFW and a public SSH allow rule. Solo saves the private SSH address without marking the server provisioned; subsequent initialization uses it and preserves the private rule. If no auth key is supplied for a new server, the command installs/starts Tailscale and exits with a join instruction. You can then enroll interactively with tailscale up over SSH, approve its browser link, and rerun solo server tailnet <public-host>.

host stays the public identity for routing/placement. Solo uses regular SSH over Tailscale; Tailscale SSH is optional. Keep the local tailnet connection active for later Solo operations. solo server ls shows the SSH address and transport. Marked tailnet connections check local Tailscale before reading credentials or opening SSH; a known offline peer also stops the attempt. A running local daemon is a prerequisite, not proof of remote SSH reachability. Older Tailscale-range/MagicDNS addresses are inferred for compatibility; use ssh_transport: direct for non-Tailscale addresses in those ranges. server init preserves a matching existing tailscale0 SSH rule when reconciling.

This narrows network access but does not resolve Solo's separate host-key/key-authentication implementation gaps; see G01.

Access monitoring privately

With Tailscale, open Grafana directly over private HTTPS; no SSH tunnel is needed. During monitoring installation, when local and remote Tailscale are connected, Solo runs tailscale serve --bg --https=8443 3000 on the monitoring server. Tailscale Serve terminates HTTPS and reverse-proxies requests to Grafana on port 3000:

https://<server-tailnet-name>:8443

Use the monitoring server's tailnet DNS name, not its public app domain. This is Tailscale Serve, separate from the public application ingress handled by solo-proxy. Inspect the remote configuration with tailscale serve status. Prometheus and the proxy management socket are not exposed through this Grafana route.

Without Tailscale Serve, use an SSH tunnel instead. Forward loopback ports over SSH, then visit http://localhost:3000:

ssh -L 3000:127.0.0.1:3000 -L 9090:127.0.0.1:9090 <user>@<server-ssh-address>

Use an SSH authentication method accepted by that host. Grafana starts with admin/admin; change it on first login. solo stats reports runtime status, a proxy health summary, and the loopback URLs/tunnel command. It currently does not discover or print the Tailscale Serve URL, so its localhost output does not mean a tunnel is required when Serve is configured.

Inspect and manage bans

solo server fw
solo server fw ban 198.51.100.20
solo server fw unban 198.51.100.20 --jail sshd
solo server fw unban-all --jail banlist
solo server exec "ufw status verbose"
solo server exec "fail2ban-client status"

Firewall commands accept --hosts with a comma-separated list of server identities. Use the configured SSH port rather than assuming port 22 when inspecting or adjusting rules.

References: Tailscale Linux installation, Tailscale and UFW.

Cloudflare in front of Solo

Cloudflare can provide public DNS, an HTTP reverse proxy, caching, and edge TLS while Solo runs the applications and origin reverse proxy. Tailscale remains the private administration network. This configuration is based on the current Solo implementation and vendor documentation; a Cloudflare-proxied deployment has not yet been live-qualified here.

Use a publicly trusted Let's Encrypt certificate on Solo and Cloudflare Full (strict) between Cloudflare and the origin. Cloudflare and Solo manage separate certificates: Cloudflare's edge certificate protects the browser connection; Solo's certificate protects the origin connection.

  1. Deploy and verify Solo directly before enabling Cloudflare proxying. Keep the server's public IP as servers[].host and its Tailscale address as ssh_host; do not use a proxied Cloudflare hostname as the SSH address.
  2. In the Cloudflare zone for jsmp.me, create or retain an A record for solo pointing to 208.85.22.24. Start with DNS only. An apps wildcard record can point to the same IP for application routing. DNS wildcards do not issue wildcard TLS certificates.
  3. Initialize Solo and deploy the services. Verify the origin's certificate and application readiness for each hostname.
  4. Confirm Cloudflare has an active edge certificate covering each hostname you intend to proxy. Then enable Proxied for those records and select SSL/TLS → Full (strict). Avoid Flexible: Solo should continue receiving HTTPS from Cloudflare.
  5. Preserve the Host header and hostname/SNI sent to Solo. If you use Origin Rules to change them, keep the selected route and origin certificate names consistent.
  6. Ensure HTTP requests to /.well-known/acme-challenge/ reach Solo unchanged on port 80. Exclude that path from custom cache rules, authentication/Access policies, Workers, redirects, and WAF/challenge rules that would replace or block the token response. Keep the path working for renewals as well as initial issuance.
  7. Recheck the public site and direct origin after enabling the proxy. Verify the configured health endpoint returns HTTP 200; HTTP success at the edge alone does not establish the direct origin's TLS configuration.

The current ACME client selects HTTP-01. No Cloudflare API token or ACME code change is needed when Cloudflare forwards that challenge correctly. This is conditional on the actual edge rules and routing; it is not a claim that arbitrary Cloudflare policies are compatible. HTTP-01 validates individual hostnames and cannot issue wildcard certificates.

A qualification-style server configuration is:

project: my-apps
servers:
  - host: 208.85.22.24
    ssh_host: 100.71.65.54
    ssh_transport: tailscale
    ssh_user: root
    domain: solo.jsmp.me
services:
  - name: api
    port: 8080
    replicas: 1
    builder:
      type: swiftPackageContainer
      sdk: x86_64-swift-linux-musl
      product: App
      swiftCommand: swiftly run -- swift
    proxy:
      host: api.apps.jsmp.me
      healthcheck:
        path: /up

Replace the qualification addresses and hostnames with your own. This example assumes a Swift package with executable product App, the container plugin, and a matching toolchain/Static Linux SDK. It describes the application service; it does not build the static Solo documentation site. The server domain bootstraps proxy HTTPS; the explicit application hostname has its own route and certificate. The live configuration in solo.jsmp.me/solo.yml uses the application namespace alongside solo.jsmp.me.

Run commands from the application project:

solo server init
solo build create api
solo deploy --app api --last-build
solo doctor
solo proxy tls

Initialization still requires the proxy image expected by your Solo build; see the getting-started guide for the currently observed public-image limitation.

Certificate coverage for apps.jsmp.me

Under Cloudflare's standard full-zone Universal SSL setup, the zone jsmp.me covers its apex and first-level names such as solo.jsmp.me and apps.jsmp.me. It does not automatically cover a deeper hostname such as api.apps.jsmp.me or swift-hello.apps.jsmp.me.

Before proxying those deeper names, provision suitable edge coverage, such as an advanced certificate covering *.apps.jsmp.me, or use another Cloudflare zone/hostname setup that explicitly provides that coverage. A DNS wildcard for *.apps.jsmp.me and a valid Solo origin certificate do not fix missing Cloudflare edge coverage. Check the active certificate's SANs before changing proxy status.

Solo can continue obtaining separate origin certificates for each app hostname through HTTP-01. A wildcard origin certificate would require DNS-01 support.

Verify public and origin paths separately

For the application hostname:

curl --http1.1 --fail https://api.apps.jsmp.me/up
curl --http2 --fail https://api.apps.jsmp.me/up
curl --resolve api.apps.jsmp.me:443:208.85.22.24 \
  --fail https://api.apps.jsmp.me/up
solo doctor

The --resolve request connects directly to the origin IP while preserving the hostname for SNI, certificate verification, and Host routing. It requires direct origin ingress to be allowed from the machine running the check. If you later restrict the origin to Cloudflare source IPs, run origin verification from an authorized network or add an appropriately scoped diagnostic path. Do not disable certificate verification to make this check pass.

Doctor's public HTTPS checks follow public DNS. Once Cloudflare proxying is enabled, they test the edge path; the SSH platform checks and origin certificate inventory remain separate evidence. Cloudflare dashboard settings and authoritative DNS permissions are not audited by the current doctor.

Client IPs, bans, and origin access

Cloudflare becomes Solo's immediate TCP peer. Solo currently uses the socket peer address for access logs and appends that address to X-Forwarded-For; it does not yet implement a configurable Cloudflare trusted-proxy policy.

Before relying on original-client IPs for bans, rate limits, or application security, add explicit trusted-proxy support: recognize current Cloudflare source ranges, honor CF-Connecting-IP only from trusted peers, and sanitize forwarded headers. Do not trust a client-supplied Cloudflare header on an origin that accepts arbitrary public connections. Review fail2ban rules so one abusive visitor cannot cause a shared Cloudflare edge address to be banned.

You can later restrict public origin HTTP/HTTPS ingress to Cloudflare's published IP ranges, with explicit exceptions for required diagnostics. Keep that allowlist current and verify the Docker-published ingress policy as well as UFW. SSH stays on Tailscale; proxy management, Prometheus, and Grafana should not become public through Cloudflare.

Changes needed for DNS-01 or Origin CA

DNS-01 is the useful next ACME extension when you need a wildcard certificate or want certificate issuance independent of inbound HTTP challenge routing. It is not implemented in Solo's ACME client today. It requires:

  • A challenge-solver interface and configuration selecting HTTP-01 or DNS-01.
  • A Cloudflare DNS provider that creates the appropriate _acme-challenge TXT record, waits for DNS propagation, triggers validation, and removes only the record it created afterward.
  • A narrowly scoped API token for the intended zone, stored as a secret and available to the certificate-issuing server. Do not put the token in solo.yml, command arguments, reports, or logs.
  • Bounded propagation/retry handling, cleanup on failure, and tests for concurrent TXT values and renewals. In clusters, retain the existing certificate-owner and synchronization rules.

Cloudflare Origin CA is a different option: Full (strict) can accept its origin certificates, but browsers do not generally trust them when connecting directly to the origin. First-class support would need an explicit externally managed certificate mode, secure certificate/key installation and reload, and diagnostics that distinguish edge trust from direct public-CA trust. Keep the current Let's Encrypt origin certificates for the simplest supported setup.

References