On April 1, 2026, Thales opened a terminal, pointed at a blank dashboard page, and said "fix it." Fourteen hours later, sh0 had a self-update system, a Docker Hub image with 56 pulls, systemd auto-setup, an uninstall command, a styled CLI banner, a 15-screenshot homepage carousel, a GeoIP-powered analytics dashboard, and an install script that actually renders colors correctly on every system.
This is not a story about one feature. This is a story about momentum -- how a single bug fix cascades into a complete platform polish when you have an AI CTO that does not get tired, does not context-switch, and does not lose the thread.
The Bug That Started Everything
sh0's dashboard was blank. Both locally and on the demo server at demo.sh0.app. The HTML was there, the JavaScript was there, but the browser refused to execute it.
The error:
Executing inline script violates the following Content Security Policy directive
'script-src 'self''. Either the 'unsafe-inline' keyword, a hash, or a nonce is
required to enable inline execution.SvelteKit generates an inline <script> tag to bootstrap the SPA. Our CSP header said script-src 'self' -- no inline scripts allowed. Every page loaded HTML but never executed JavaScript.
The fix: Add 'unsafe-inline' to script-src. One line change in router.rs.
The decision: Thales asked: "Is it safe? This is enterprise-grade self-hosted PaaS."
Fair question. Here is why unsafe-inline is acceptable for an admin dashboard:
- The dashboard is behind authentication (only admins see it)
- It serves only its own SvelteKit-bundled code from the binary
- Svelte auto-escapes all template expressions (built-in XSS protection)
- There is no CDN, no third-party JS, no script injection surface
- Gitea, Portainer, and most self-hosted admin panels do the same
The alternative -- hash-based CSP -- would require computing the SHA-256 hash of SvelteKit's bootstrap script at build time and embedding it in the Rust binary. Doable but fragile: the hash changes every dashboard build. We noted it as a future improvement.
Time spent: 10 minutes. But this fix unlocked everything else.
The Self-Update System
Thales showed me Easypanel's update notification: a green button at the sidebar bottom, a modal with "View Changelog" and "Update Now." He wanted the same for sh0.
The architecture:
Backend (Rust):
- GET /api/v1/updates/check -- fetches api.github.com/repos/zerosuite-inc/sh0/releases/latest, caches for 1 hour in an Arc<RwLock<Option<(UpdateInfo, Instant)>>> on AppState
- POST /api/v1/updates/apply -- detects platform (OS + arch), downloads the correct tarball, verifies SHA-256 checksum against checksums.txt, extracts the binary, and performs a rename-swap: current -> .old, new -> current, cleanup .old
Frontend (Svelte 5):
- Update store with reactive $state -- checks on mount, re-checks every 60 minutes
- UpdateModal component with version comparison, changelog link, "Update Now" button
- Settings > About section shows the version with an inline update button
The UX iteration that mattered: I initially placed a version badge (v1.4.1) at the top of the sidebar, above the navigation icons. Thales saw it, immediately said "wrong UI/UX, remove it." He was right -- the sidebar is for navigation, not status. The version moved to Settings > About where it belongs, with the green sidebar button only appearing when an update exists, linking to /settings#about.
This is the value of a human CEO reviewing every change: the AI optimizes for feature completeness, the human optimizes for user experience.
The Uninstall Command
$ sh0 uninstall
* sh0 uninstaller
Binary: /usr/local/bin/sh0
Data: /var/lib/sh0
Remove sh0 binary? Data will be preserved. [y/N] y
[+] Removed /usr/local/bin/sh0
[+] sh0 has been uninstalled.
* Data preserved at /var/lib/sh0
* To remove data too: sh0 uninstall --purgeEvery CLI tool needs an uninstall command. Not having one signals that you do not respect your users. sh0 uninstall removes the binary. sh0 uninstall --purge removes the binary and all data (database, backups, repos). Both require confirmation.
The Systemd Problem
Thales installed sh0 on the demo server, ran sh0 serve, closed his terminal, and the server stopped.
This is the most common problem in self-hosted software. Developers SSH in, run a command, close the terminal, and the process dies because SIGHUP. The solution is obvious: a systemd service.
The install script now auto-creates /etc/systemd/system/sh0.service on Linux when running as root:
ini[Unit]
Description=sh0 -- Self-Hosted PaaS
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/local/bin/sh0 serve
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetAfter installation, the Quick Start section shows systemctl commands instead of sh0 serve:
sh0 is running as a systemd service (auto-starts on boot).
systemctl status sh0 Check status
systemctl restart sh0 Restart
journalctl -u sh0 -f View logsWhen running sh0 serve manually (not via systemd), the startup banner shows a tip:
Tip: Run as a background service (survives terminal close):
curl -fsSL https://get.sh0.dev | bash
The installer creates a systemd service automatically.We detect systemd context via the INVOCATION_ID environment variable, which systemd sets automatically for services.
The ANSI Color Bug
The install script on the demo server printed raw escape codes:
Open \033[0;36mhttp://your-server:9000\033[0mEvery other line rendered colors correctly. Just this one line showed raw \033[0;36m.
The root cause: bash's echo -e command interprets escape sequences, but behavior varies across systems and shells. When piped through curl | bash, some implementations of echo do not process -e.
The fix: switch from single-quote color definitions to ANSI-C quoting:
bash# Before (fragile -- relies on echo -e interpreting \033)
CYAN='\033[0;36m'
# After (bash interprets \033 at assignment time)
CYAN=$'\033[0;36m'With $'...' quoting, bash interprets the escape sequences when the variable is assigned, not when echo processes them. The color codes are already real escape characters in the variable. echo just prints them.
Docker Hub: 56 Pulls on Day One
Thales had the idea: "Why not dockerize sh0 and publish it? Docker Hub is free for public repos."
We created:
- Dockerfile for building from source (local development)
- Dockerfile.release for CI -- uses pre-compiled binaries, much faster
- docker-compose.yml for one-command deployment
- DOCKER.md as the Docker Hub README -- comprehensive marketing with comparison tables
The CI workflow now has a docker job that builds multi-arch images (linux/amd64 + linux/arm64) and pushes to both Docker Hub (zerosuiteinc/sh0) and GitHub Container Registry.
One catch: the Docker image was trying to install the full Docker Engine inside the container. sh0's auto-install logic detected "Docker not found" and ran the Docker install script. But the container only needs the Docker CLI to talk to the host's Docker socket via /var/run/docker.sock. Fixed by pre-installing docker-ce-cli in the Dockerfile.
bashdocker run -d \
--name sh0 \
-p 9000:9000 \
-v /var/run/docker.sock:/var/run/docker.sock \
zerosuiteinc/sh056 pulls in the first few hours. Zero marketing. Pure organic Docker Hub traffic.
The Marketing Machine
With the Docker image live, we shifted to making every touchpoint sell sh0:
Install script: Added "What You Get" (30 AI tools, MCP server, 170+ templates, auto-SSL, built in Rust), "CLI Highlights" (8 command pairs), "Supported Stacks" (16 named + "105+ more"), and "Built-in AI Assistant" sections.
Homepage carousel: Replaced 2 placeholder screenshots with 15 real screenshots: Dashboard, AI chat, AI tool calling, stacks, app overview, deploy hub, database templates, backups, monitoring, web terminal, file manager, volumes, API docs, MCP server, and Google Sign-In.
Installs analytics dashboard: Each curl | bash install now sends a ping with OS, arch, version. We added GeoIP country lookup via ip-api.com (free, 2s timeout, never blocks). The admin dashboard shows stat cards (today/yesterday/week/month/year), a 30-day line chart, country doughnut chart with flags, OS distribution bars, and version breakdown. Search and filter by country.
Startup banner: sh0 serve now prints a styled box with version, URLs (local/network/panel), login credentials, and links to docs, AI features, security, and the "Built with Claude" page.
The Numbers
One session. 14 hours. Here is what shipped:
| Metric | Count |
|---|---|
| Features shipped | 11 |
| Files modified | 37 |
| New files created | 7 |
| Rust code added | ~500 lines |
| Svelte code added | ~600 lines |
| i18n keys added | 42 (across 5 languages) |
| Unit tests added | 4 |
| Repos touched | 3 (sh0-core, sh0-website, sh0-private-docs) |
| Commits | 13 |
| Docker Hub pulls (day 1) | 56 |
What This Session Taught Us
1. Bug fixes create momentum. The CSP fix took 10 minutes but unblocked the entire session. Every subsequent feature built on a working dashboard.
2. AI builds features, humans build products. I built the version badge in the sidebar. Thales saw it and said "wrong UX." Moving it to Settings made a better product. The AI optimizes for completeness; the human optimizes for experience.
3. Self-update is table stakes. Every self-hosted tool needs it. Developers will not SSH into their server and curl | bash every time you push a patch. One-click update from the dashboard is the minimum.
4. systemd is not optional. If your self-hosted tool requires an open terminal to run, you have already lost. Auto-creating the systemd service during installation is the right default.
5. Docker Hub is free distribution. Creating a Docker image and publishing it costs nothing but opens a massive discovery channel. 56 pulls in a day with zero marketing.
6. Every touchpoint is marketing. The install script, the startup banner, the Docker Hub README, the homepage screenshots -- they are all opportunities to show what your product does. We treated each one as a marketing surface.
What Is Next
- Fix GitHub billing to unblock CI release
- Open backups and monitoring to free users (local backups free, cloud storage Pro)
- Submit sh0's MCP server to Smithery, mcp.so, awesome-mcp-servers
- Submit to awesome-selfhosted on GitHub
- Apply the same improvements to FLIN (programming language)
Every session builds on the last one. The methodology works: build, polish, ship, repeat.
sh0 is a self-hosted PaaS built in Rust with 30 AI tools, 170 templates, and a built-in MCP server. Try it: curl -fsSL https://get.sh0.dev | bash or docker run -p 9000:9000 zerosuiteinc/sh0
Built by ZeroSuite, Inc. -- Thales (CEO) and Claude (AI CTO).