Guides
Build and deploy an app, then inspect and operate your server.
Getting started · Static sites · Vultr demo · Private administration · Cloudflare configuration
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
soloon 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
dockerforpodmanin the build command. Checksolo 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 versionstates the current policy. - Proxy initialization fails: check server
domain, DNS, inbound 80/443, andsolo 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, orsolo app rollbackcommand. - 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
| Endpoint | Standard exposure |
|---|---|
| HTTP / HTTPS | Public TCP 80 / 443 on solo-proxy |
| SSH | Configured SSH port; can be migrated to Tailscale |
| Proxy management | Private Unix socket, accessed through SSH |
| Proxy metrics | Port 9091 inside the Docker network |
| Prometheus | Server loopback 127.0.0.1:9090 |
| Grafana | Server loopback 127.0.0.1:3000; optional private Tailscale Serve |
Service expose ports | Explicit 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.
Recommended setup with the current ACME client
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.
- Deploy and verify Solo directly before enabling Cloudflare proxying. Keep the server's public IP as
servers[].hostand its Tailscale address asssh_host; do not use a proxied Cloudflare hostname as the SSH address. - In the Cloudflare zone for
jsmp.me, create or retain an A record forsolopointing to208.85.22.24. Start with DNS only. Anappswildcard record can point to the same IP for application routing. DNS wildcards do not issue wildcard TLS certificates. - Initialize Solo and deploy the services. Verify the origin's certificate and application readiness for each hostname.
- 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.
- 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.
- 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. - 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-challengeTXT 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.