How do you monetize a self-hosted product? The user downloads your binary, runs it on their own server, and has full control over the machine. There is no SaaS billing page they have to visit. There is no usage meter ticking in the background. The entire product runs locally, and any sufficiently motivated person could patch the binary to remove your license checks.
This is the fundamental tension of self-hosted software monetization, and we spent a significant amount of time thinking about it before writing a single line of license enforcement code. The answer we arrived at was not a technical one. It was a philosophical one: make the free tier so generous that most users never need to upgrade, and make the paid tiers valuable enough that the users who do need them are happy to pay.
The Pricing Philosophy
We had three principles:
Principle 1: Free must be genuinely useful. A solo developer should be able to deploy apps, use the terminal, run cron jobs, configure deploy hooks, set up preview environments, manage API keys, deploy Docker Compose stacks, use the sh0.yaml IaC system, and set a custom panel domain -- all without paying anything. If your free tier requires an upgrade to do basic development work, it is not a free tier. It is a trial.
Principle 2: Pro gates operational features, not core features. The Pro tier ($19/month) unlocks automated backups, uptime monitoring, alerts, status pages, code health analysis, and stackshot exports. These are features you need when you are running production workloads and care about reliability. A hobbyist deploying a side project does not need automated backup scheduling. A startup running customer-facing services does.
Principle 3: Business gates team features. The Business tier ($97/month) unlocks RBAC, team members, auto-scaling, and multi-server clusters (BYOS). These are features that matter when multiple people are managing the same platform. A solo developer does not need role-based access control. A team of five does.
The Plans Alignment Session
We had a dedicated session (March 17) where we aligned all code with a new plans document (PLANS.md). The previous model was too restrictive -- terminal access, cron jobs, deploy hooks, preview environments, and API keys were all gated behind Pro. This made the free tier nearly useless for real development work.
The alignment session touched 12 code locations:
- Rust model (
license.rs): Removed 5 always-true methods that gated features we decided should be free (allows_terminal,allows_cron,allows_hooks,allows_previews,allows_api_keys). Added 4 new methods for genuinely Pro/Business features (allows_status_pages,allows_code_health,allows_stackshots,allows_team_members). Addedaudit_retention_days()(Free = 7 days, Pro = 90 days, Business = 365 days).
- API handler: Updated the
GET /api/v1/settings/licenseresponse to reflect the new feature set.
- Dashboard license page: Rewrote the plan comparison table with new prices ($0 / $19 / $97) and the new feature breakdown.
- Website pricing page: Removed the lifetime pricing toggle (we decided against lifetime licenses), rewrote all three plan cards.
- 5 i18n locale files: Updated upgrade prompts in English, French, Spanish, Portuguese, and Swahili.
- Checkout endpoints: Updated Stripe and ZeroFee price amounts, removed lifetime payment mode.
Pricing for African Markets
The pricing was deliberately set with African developers in mind. Most PaaS platforms charge $10-25 per seat per month, or $50+ for team features. Our Pro at $19 and Business at $97 are competitive globally, but we also integrated ZeroFee -- a Mobile Money payment gateway that supports 135+ providers across 18+ African countries. A developer in Abidjan can pay for sh0 Pro with Orange Money. A team in Nairobi can pay with M-Pesa. This is not a cosmetic feature. In many African markets, international credit cards are difficult to obtain, and Mobile Money is the primary digital payment method.
The Enforcement Layer
With the philosophy settled, the implementation was straightforward. Three components: an error variant, shared helpers, and per-handler gates.
ApiError::LicenseRequired
#[derive(Debug)]
pub enum ApiError {
// ... existing variants
LicenseRequired {
message: String,
required_plan: String,
},
}impl IntoResponse for ApiError { fn into_response(self) -> Response { match self { ApiError::LicenseRequired { message, required_plan } => { let body = json!({ "error": { "code": "LICENSE_REQUIRED", "message": message, "required_plan": required_plan, } }); (StatusCode::FORBIDDEN, Json(body)).into_response() } // ... } } } ```
The key design decision: the error response includes required_plan as a structured field. The frontend does not have to guess which plan is needed -- the backend tells it, and the upgrade modal can show the correct plan and pricing. This matters because different features require different plans. Backups require Pro. Team management requires Business. The error response drives the UI directly.
Shared Helpers
pub fn require_plan(state: &AppState, plan: &str) -> Result<(), ApiError> {
let license = state.license.read();
match &*license {
Some(lic) if lic.plan.allows(plan) => Ok(()),
_ => Err(ApiError::LicenseRequired {
message: format!("This feature requires the {} plan or higher", plan),
required_plan: plan.to_string(),
}),
}
}pub fn require_pro(state: &AppState) -> Result<(), ApiError> { require_plan(state, "pro") }
pub fn require_business(state: &AppState) -> Result<(), ApiError> { require_plan(state, "business") } ```
Three functions. One line to call in any handler. The require_plan function is the general form, and require_pro / require_business are convenience wrappers for the two most common cases. Before this refactoring, the nodes.rs handler had its own local copy of the Business check -- the shared helpers eliminated that duplication.
Handler Enforcement
Adding a license gate to a handler is a single line at the top of the function:
pub async fn trigger_backup(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<TriggerBackupRequest>,
) -> Result<Json<BackupResponse>> {
require_pro(&state)?; // One line. That's it.// ... rest of the handler } ```
We added gates to 10 handler files covering 25+ functions:
| Handler | Gate | Features Blocked |
|---|---|---|
backups.rs | Pro | All 10 backup operations (trigger, schedule, list, restore, delete) |
alerts.rs | Pro | Alert creation |
uptime.rs | Pro | Uptime check creation |
export.rs | Pro | Stackshot exports |
projects.rs | Business | Team member add/update/remove; Free limited to 1 stack |
scaling.rs | Business | Auto-scaling configuration |
team.rs | Business | All 4 team operations (invite, list, update, remove) |
nodes.rs | Business | All multi-server operations |
The stack creation limit deserved special treatment. Instead of a boolean gate, it uses a count check:
pub async fn create_project(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateProjectRequest>,
) -> Result<Json<ProjectResponse>> {
let count = Project::count(&state.pool)?;
let max = state.license.read()
.as_ref()
.map(|l| l.plan.max_stacks())
.unwrap_or(1); // Free = 1 stackif count >= max { return Err(ApiError::LicenseRequired { message: format!("Free plan allows {} stack(s). Upgrade to create more.", max), required_plan: "pro".to_string(), }); } // ... } ```
Free users get 1 stack (project). Pro and Business get unlimited. This is enough for a solo developer to deploy a meaningful application (a stack with multiple services), while making the upgrade path clear once they need to manage multiple projects.
Dashboard Enforcement
Server-side gates prevent the operation, but a good user experience catches it earlier -- before the API call. The dashboard enforces license gates at two levels.
Page-Level Gates
For entire pages that are plan-gated (backups, monitoring, team, nodes), the gate is at the page level:
<script>
import { isProOrAbove } from '$lib/stores/license';
import UpgradePrompt from '$lib/components/UpgradePrompt.svelte';
</script>{#if isProOrAbove()}
{:else}
The user sees the upgrade prompt immediately, without making an API call that would fail anyway.
API Error Interception
For operations within a page (like creating a second stack on the home page), the gate catches the API error:
async function createStack() {
try {
await stacksApi.create(formData);
} catch (e) {
if (e.required_plan) {
showUpgradePrompt = true;
upgradePlan = e.required_plan;
} else {
toast.error(e.message);
}
}
}The ApiError class in the frontend was extended with a required_plan field. Both api() and apiRaw() extract it from the structured error response. The stacks page uses e.required_plan ?? 'pro' so the UpgradePrompt shows the correct plan dynamically.
The UpgradePrompt Component
The UpgradePrompt.svelte component existed before the enforcement session -- it was created during the license system phase but never actually used. It shows a modal with two actions: "View Pricing" (links to sh0.dev/pricing) and "Enter License Key" (navigates to the /license page in the dashboard). The plan prop determines which plan to highlight and which pricing to show.
What We Deliberately Left Unenforced
Not every feature with a plan association is enforced at the API level. Some features are self-enforcing:
- Audit retention: The
audit_retention_days()method returns 7/90/365 based on the plan, but no background job prunes old audit logs yet. When the pruner is built, it will read the plan and delete accordingly. - License expiration: The
valid_untilfield exists on the license model but is never checked. Expired licenses should downgrade to Free, but this is a future session.
We also have no license enforcement tests. This is a conscious debt. A refactor could silently remove a require_pro() call, and no test would catch it. Adding integration tests that verify "Free user gets 403 on backup trigger" is on the roadmap.
The Self-Hosted Trust Model
We do not obfuscate the license checks. We do not phone home. We do not use license servers. The check is a function call in the handler that reads a local license record from SQLite. A sufficiently motivated user could patch the binary, modify the database, or write a migration that sets their plan to "business."
This is fine. Self-hosted software operates on a trust model. The users who would patch the binary are not the users who would pay for a license. The users who pay are teams and companies that want official support, timely updates, and the confidence that they are running a maintained product. License enforcement exists to make the business model legible -- to make it clear what each tier includes and to prompt the upgrade at the right moment -- not to make piracy impossible.
If the free tier is generous enough that people actually use it, some of them will grow into paying customers. That is the bet. And making sh0 free for solo developers while charging teams for team features is, we believe, the right bet for self-hosted infrastructure software.
---
This is Part 34 of the "How We Built sh0.dev" series. Next up: the grand finale -- 14 days, 105 sessions, 1 AI CTO. The complete story of building sh0.dev.