How we built a Softaculous-style deploy hub with 183 options across 5 categories, 7 deploy form components, and a split-panel UX.
Thales & Claude| March 25, 2026 11 minsh0
deployuxdashboardsveltetemplatespaassoftaculous
The hardest part of self-hosted software is the first deploy. Not because the technology is difficult -- Docker has made that nearly trivial -- but because the UI makes you feel like you need to know Docker before you can use it.
Coolify asks you to pick a "resource type" from a dropdown. Easypanel shows a form with fields like "Docker image" and "port mapping." CapRover wants a tar file. For developers who live in Docker, this is fine. For the developer who just wants to run WordPress, or the agency that needs to spin up a Next.js site for a client, it is a wall.
We wanted something different. We wanted the experience of Softaculous -- the one-click installer panel that has been shipping with cPanel for over a decade. Open a page, see everything you can deploy, click the one you want, fill in a name, and go.
On March 15, 2026, we built that page. One hundred and eighty-three deploy options. Five categories. Seven specialised form components. A split-panel layout that shows the catalog on the left and the deploy form on the right. And it works.
The Catalog
The foundation is a TypeScript file called deploy-catalog.ts that defines every deployable option as a structured object:
PostgreSQL, MySQL, MongoDB, Redis, MariaDB, Cassandra, ClickHouse, etc.
Apps
87
WordPress, Ghost, Discourse, Gitea, Minio, Grafana, etc.
Every option specifies its formType, which determines which deploy form component renders when the user selects it. A PostgreSQL database uses FormService.svelte (one-click, auto-generated name). A Next.js app uses FormFramework.svelte (Git URL, pre-filled build and start commands). A raw Docker image uses FormDockerImage.svelte (image name, port, env vars).
The catalog is pure data -- no components, no side effects. It is imported by the deploy page and filtered client-side. This means search, category filtering, and sub-group navigation are all instant. No API calls, no loading spinners.
The Page Layout
The deploy page uses a split-panel design inspired by email clients and IDE settings panels:
Left panel (60% on desktop, full width on mobile): The catalog grid. At the top, a full-width search bar with 150ms debounce, autofocus, and a live result count. Below that, category tabs (All, Source Types, Frameworks, Databases, Apps) with count badges. Below the tabs, sub-group pills appear when a category is selected -- for example, selecting "Frameworks" shows pills for JavaScript, Python, PHP, Go, Rust, Java, .NET, Ruby, Elixir. Below the pills, the grid of deploy cards, grouped by sub-category with section headers.
Right panel (40% on desktop, stacked below on mobile): The deploy form. Appears when a card is selected. Shows the selected option's icon and name at the top, a mandatory stack selector, and the appropriate form component below.
When no option is selected, the right panel shows a featured section with six popular options: Git Repo, Upload ZIP, WordPress, Next.js, PostgreSQL, and Docker Image. These are the "I just want to get started" shortcuts.
The left panel dims to 40% opacity when an option is selected, keeping the catalog visible but directing attention to the form. This is a subtle but effective visual cue: "you have chosen something, now fill in the details."
The Seven Form Components
Each deploy method has its own form component, tailored to that method's specific inputs:
FormGit.svelte -- The most common form. Fields: app name, Git repository URL, branch (default: main), port, and an expandable environment variables section. The Git URL is validated client-side to catch obvious mistakes before hitting the API.
FormUpload.svelte -- Drag-and-drop file upload for .zip and .tar.gz files. Uses the HTML5 File API with a drop zone that highlights on drag-over. Shows the selected file name and size before submission.
FormDockerImage.svelte -- For pulling pre-built images. Fields: app name, Docker image (e.g., nginx:latest), port, and environment variables. The image name supports both Docker Hub shorthand (nginx) and full registry paths (ghcr.io/org/image:tag).
FormDockerfile.svelte -- A text area for pasting a raw Dockerfile. The backend will build the image and run the resulting container. Useful for custom setups that do not fit any template.
FormCompose.svelte -- A text area for pasting a docker-compose.yml file, with a "Validate" button that checks the YAML syntax before deployment. This form creates multiple services at once, each one tracked as a separate app within the stack.
FormService.svelte -- The one-click form. Used for databases and pre-configured apps like WordPress or Gitea. The name is auto-generated (e.g., postgres-7f3a), the image and configuration are pre-set by the catalog entry. The user literally clicks "Deploy" and waits.
FormFramework.svelte -- A specialised Git deploy form for frameworks. Pre-fills the build command (e.g., npm run build for Next.js, cargo build --release for Rust) and start command (e.g., npm start, ./target/release/app). The user provides the Git URL and optionally overrides the defaults.
All seven forms share a common pattern at the top: the mandatory stack selector.
The Stack Selector Problem
This was a bug we caught and fixed during the same session. The original Deploy Hub had no concept of stacks -- it created apps without a project_id, which broke the stack-scoped architecture we had just built.
The fix was a StackSelector component at the top of every form:
<!-- Inside DeployForm.svelte -->
<script lang="ts">
let stacks = $state<Stack[]>([]);
let selectedStackId = $state<string | null>(preSelectedStackId);
let newStackName = $state('');
let creatingStack = $state(false);
$effect(() => {
projectsApi.list().then(data => {
stacks = data;
// Auto-select if only one stack exists
if (stacks.length === 1 && !selectedStackId) {
selectedStackId = stacks[0].id;
}
});
});
{#if creatingStack}
e.key === 'Enter' && createStack()} />
{:else}
{/if}
```
Three behaviours make this seamless:
1. Auto-select. If only one stack exists, it is pre-selected. No extra click needed for the common case.
2. URL parameter. Navigating from a stack's "Add Service" button sets ?stack=id, which pre-selects that stack. The user never has to pick.
3. Inline creation. If you arrive at the Deploy Hub without a stack, you can create one without leaving the page. Type a name, press Enter, and it appears in the dropdown, already selected.
Every form component validates that a stack is selected before submission. No stack, no deploy. This constraint is enforced at the UI level and again at the API level (the backend rejects apps without a project_id).
The Icon Mapping Problem
The catalog defines 183 options, each with an icon name like "globe", "database", "docker", or "code". These need to render as actual Lucide Svelte components. But you cannot dynamically import a Svelte component by string name at runtime.
The solution was icon-map.ts: a static mapping from string names to imported Lucide components.
export function getIcon(name: string) {
return iconMap[name] || Package; // fallback to Package icon
}
```
The DeployCard.svelte component calls getIcon(option.icon) and renders it with . Tree-shaking removes any Lucide icons not referenced in the map, so the bundle stays reasonable despite importing from a library with 1,500+ icons.
The Search Experience
Search is the first thing users see. The search bar is autofocused on page load, and as you type, the grid filters in real time. The implementation is straightforward -- a debounced filter over the option name and description:
The result count updates live next to the search bar: "183 options" becomes "12 results" as you type "post" (matching PostgreSQL, PostgREST, Postal, etc.). The category tabs and sub-group pills also update their counts to reflect the filtered set.
This immediate feedback loop is what makes the Deploy Hub feel fast. There is no "Search" button, no loading state, no round-trip to the server. The entire catalog is in memory because it is static data -- 183 objects weighing perhaps 30 KB. Client-side filtering is the correct architecture here.
Replacing the Add Service Modal
The Deploy Hub's existence made the AddServiceModal from the stack redesign redundant. The modal had three tabs (Services, Templates, Custom) that were a strict subset of what the Deploy Hub offered. Keeping both would mean maintaining two deploy experiences.
We deleted the modal entirely. The "Add Service" button in the context sidebar became a link: . The home page's "New Deploy" quick action card was fixed from href="/" to href="/deploy".
One deploy surface. One set of form components. One place to maintain.
The i18n Addition
The Deploy Hub added 42 translation keys to all five locale files. The keys cover the search bar, category names, form labels, validation messages, success/error states, and the featured section. French translations include correct diacritics throughout -- "deployer," "deploiement," "donnees," "resultat," "categorie." Spanish and Portuguese likewise use correct accents: "aplicacion," "compilacion," "repositorio," "variaveis."
What We Learned
A catalog is a product decision, not just a UI decision. Choosing to list 183 options -- instead of showing a blank form and saying "enter a Docker image" -- is a statement about who our users are. They are developers who want to deploy things, not Docker operators who want to manage containers. The catalog bridges the gap.
Split-panel layouts work for selection-then-configuration flows. The left panel is browse, the right panel is act. The dimming of the left panel when a selection is made is a small detail that reduces cognitive load: "you are done browsing, now focus here."
The stack selector solved a real architectural problem. Without it, apps would leak out of the stack model, breaking navigation and grouping. By making stack selection mandatory and seamless, we maintained the structural integrity of the UX.
Static data beats API calls for catalogs. The 183 options never change at runtime. They change when we ship a new version. Encoding them as TypeScript data means search and filtering are instantaneous, and the page works offline.
The Deploy Hub is arguably the most important page in the sh0 dashboard. It is the answer to "I just installed sh0, now what?" Open the Deploy Hub, pick something, fill in a name, deploy. That is the experience we were building toward from the very first crate.