A modern, self-hosted web hosting management platform — an open-source alternative to WHMCS. Built with ASP.NET Core 9, Nuxt 4, and Vue 3.
License: Business Source License 1.1 — free to self-host, source-available, converts to GPL v2 on 2028-05-15.
https://hostpanel.innovayse.com
| Role | Password | |
|---|---|---|
| Admin | superadmin@hostpanel.com | Admin123! |
| Reseller | reseller@hostpanel.com | Admin123! |
| Client | customer@hostpanel.com | Admin123! |
- Client Portal — order services, manage domains, view invoices, open support tickets
- Admin Panel — full control over clients, products, billing, provisioning, and integrations
- Billing — invoices, payment gateways (Stripe, PayPal, bank transfer)
- Domain Management — registrar integrations (Namecheap, NameAm)
- Hosting Provisioning — cPanel/WHM and CWP support
- Multi-language — English, Russian, Armenian (hy)
- Plugin SDK — extend functionality via the
Innovayse.SDK - WHMCS Migration — import clients and data from existing WHMCS installations
| Layer | Technology |
|---|---|
| Backend API | ASP.NET Core 9, Wolverine, EF Core 8, PostgreSQL |
| Client Portal | Nuxt 4, Vue 3, TypeScript, Tailwind CSS, Pinia |
| Admin Panel | Vite, Vue 3, TypeScript, Tailwind CSS, shadcn-vue |
| Messaging | RabbitMQ + Wolverine outbox |
| Auth | ASP.NET Core Identity, JWT + Refresh Tokens |
| MailKit (dev: MailHog) | |
| Docs | Scalar |
- Docker and Docker Compose v2
- (For local development without Docker) .NET 9 SDK, Node.js 22+, yarn, PostgreSQL 17
The client portal needs Node 22, not 20: Nuxt's
nitropackpullsrollup-plugin-visualizer@7, which declaresengines.node >= 22, andyarn installrefuses on anything older.docker/client.Dockerfilealready usesnode:22-alpine.
# 1. Clone the repository
git clone https://github.com/innovayse/hostpanel.git
cd innovayse
# 2. Copy and configure environment variables
cp .env.example .env
# Edit .env — set JWT_SECRET, and NUGET_INNOVAYSE_CREDENTIALS if you are building
# the API image yourself (see below)
# 3. Start all services
docker compose up -d
# The API applies pending migrations on startup, so there is no separate step.
# Follow it with: docker compose logs -f hostpanel-api
# 4. Open in browser
# Client portal: http://localhost:3001
# Admin panel: http://localhost:5174
# API docs: http://localhost:5149/scalar
# MailHog (dev): http://localhost:8027The API depends on Innovayse.Auth, published to a private package registry, and
the image restores it while it builds. Set NUGET_INNOVAYSE_CREDENTIALS in .env
to a deploy token scoped to read_package_registry — the format is in
.env.example. Without it the build stops at NU1301 … 401 (Unauthorized).
Copy .env.example to .env and fill in the values:
| Variable | Description | Default |
|---|---|---|
POSTGRES_HOST |
PostgreSQL host the API connects to | hostpanel-db |
POSTGRES_PORT |
PostgreSQL port | 5432 |
POSTGRES_USER |
PostgreSQL username | postgres |
POSTGRES_PASSWORD |
PostgreSQL password | postgres |
POSTGRES_DB |
Database name | innovayse_dev |
JWT_SECRET |
JWT signing key (min 32 chars) | change this |
RABBITMQ_HOST |
Broker host the API connects to | rabbitmq |
RABBITMQ_PORT |
Broker AMQP port | 5672 |
RABBITMQ_USER |
RabbitMQ username | guest |
RABBITMQ_PASSWORD |
RabbitMQ password | guest |
SMTP_HOST |
SMTP server host | mailhog |
SMTP_PORT |
SMTP server port | 1025 |
SMTP_USERNAME |
SMTP username; empty for a local catcher, which refuses AUTH |
(empty) |
SMTP_PASSWORD |
SMTP password | (empty) |
SMTP_ENCRYPTION |
tls (STARTTLS, 587), ssl (implicit TLS, 465) or none. Required — the API refuses to start without it rather than guess from the port |
none |
SMTP_FROM |
Sender address on outgoing mail | noreply@innovayse.com |
SMTP_FROM_NAME |
Sender display name | Innovayse |
For the client portal, copy client/.env.example to client/.env.
AUTH_MODE decides how people sign in. .env.example ships sso; a standalone
box sets local.
sso |
local |
|
|---|---|---|
| Client portal | Innovayse SSO | email and password, against this database |
| API | SSO tokens | JWT from POST /api/auth/login |
| Admin panel | works | cannot be signed into — see below |
Its sign-in page offers one button, "Sign in with Innovayse SSO", and the SPA holds
no token of its own: it asks GET /api/auth/me on every load and reads a session
cookie it cannot see. That cookie is issued by the API's OIDC exchange, which only
runs under AUTH_MODE=sso.
Under local there is no such cookie. POST /api/auth/login succeeds and returns a
JWT — the client portal uses it — but nothing in the admin SPA sends a bearer token,
so /api/auth/me answers 401 and the panel stays on its login screen. sso-api,
the service that would issue the cookie, is addressed by docker-compose.yml but
defined in the platform overlay rather than here, so docker compose up on its own
never starts one.
Nothing in the panel is broken; there is simply no way in. Until that changes, a
standalone deployment administers itself through the API, with a token from
POST /api/auth/login:
TOKEN=$(curl -s -X POST http://localhost:5149/api/auth/login \
-H 'Content-Type: application/json' -H 'X-Requested-With: XMLHttpRequest' \
-d '{"email":"you@example.com","password":"…"}' | jq -r .accessToken)
curl -s http://localhost:5149/api/products -H "Authorization: Bearer $TOKEN"Closing this properly means choosing between issuing a session cookie in local mode and letting the SPA carry a bearer token again — the second being what the SPA was deliberately moved away from. That is a design decision, not an oversight, and it is tracked as an issue rather than guessed at here.
innovayse/
├── backend/ # ASP.NET Core solution
│ └── src/
│ ├── Innovayse.API/ # Controllers (thin, Wolverine dispatch)
│ ├── Innovayse.Application/ # CQRS handlers, validators, DTOs
│ ├── Innovayse.Domain/ # Entities, value objects, interfaces
│ ├── Innovayse.Infrastructure/ # EF Core, repositories, integrations
│ ├── Innovayse.Providers.CWP/ # CWP provisioning provider
│ └── Innovayse.SDK/ # Plugin SDK
├── client/ # Nuxt 4 client portal
│ ├── pages/ # Routes: data, SEO, business logic
│ ├── templates/ # Storefront designs — presentation only
│ │ ├── registry.ts # name + slot → component
│ │ ├── aurora/ # Default design
│ │ └── classic/ # Original design
│ └── components/ # Shared component library
├── admin/ # Vue 3 admin panel
├── docker/ # Dockerfiles and nginx config
├── docker-compose.yml
└── .env.example
cd backend
dotnet restore
dotnet run --project src/Innovayse.APIappsettings.Development.json is committed and holds only local values, so this runs
without copying a template first. It used to be an appsettings.Development.example.json
you had to copy — which was a quiet hazard, because the copy is gitignored nowhere and
went into the next commit along with whatever real credential had been filled into it.
Anything genuinely secret stays out of the repository. Use user-secrets rather than editing a settings file:
dotnet user-secrets init
dotnet user-secrets set "Sso:Authority" "https://sso.example.com"cd client
yarn install
yarn dev # http://localhost:3000cd admin
npm install
npm run dev # http://localhost:5173The public portal ships two designs and renders whichever one is selected. Both
produce identical SEO output — canonical, hreflang and schema.org live in
client/pages/, never in a template.
Set the site's own origin, or the sitemap will advertise whatever host each
request happened to arrive on — behind a proxy that can be the container's
internal address. NUXT_PUBLIC_BASE_URL covers it at build time; a prebuilt
image reads NUXT_SITE_URL at startup, which overrides the built-in value.
| Template | Description |
|---|---|
aurora |
Default. Dark and light modes, Armenian typography, live domain search |
nova |
Conversion-focused hosting landing page: pricing, plan comparison, security, migration, FAQ |
classic |
The original storefront design |
nova supplies its own header, footer, homepage and hosting page, and reuses
aurora's domain search and checkout — the checkout is the ordering path, and a
second copy of it would be a second place for a payment bug to live. Its design
tokens are prefixed --n-* and declared only under .tpl-nova and
html[data-template='nova'], so activating it cannot disturb the other two.
Several of its cards state things this codebase cannot verify — NVMe storage, a CDN, server-side caching, a firewall, a malware scanner, a backup schedule, an uptime figure, free migration. Each waits on a setting and is hidden until an operator turns it on:
| Setting | Shows |
|---|---|
portal.features.storage |
the SSD/NVMe storage card |
portal.features.caching |
the server-side caching card |
portal.features.cdn |
the CDN card |
portal.features.firewall |
the firewall card |
portal.features.malware |
the malware-protection card |
portal.features.backups |
the backup cards, in the hero, trust bar and security grid |
portal.trust.uptime |
the uptime figure in the trust bar, printed verbatim |
portal.migration.free |
the "free migration" badge, when set to true |
Any non-empty value turns the first six on; the uptime setting holds the figure itself. The comparison table and the testimonials section are data-driven the same way: the table renders only the specification lines entered under Admin → Products → Specification, and the testimonials section does not render at all, because nothing serves customer quotes yet.
Choosing one. In order of precedence:
- The
portal.templatesetting, editable in Admin → Settings → Portal appearance - The
NUXT_PUBLIC_PORTAL_TEMPLATEenvironment variable aurora
An unrecognised value falls back to aurora rather than leaving the site blank, so a
typo in the admin field cannot take the storefront down.
Adding a template. Create client/templates/<name>/ with layout/Header.vue,
layout/Footer.vue and a pages/ component for each route, then register the
loaders in client/templates/registry.ts and add the name to TEMPLATE_NAMES in
client/templates/types.ts. A unit test asserts every template implements every
slot, so a missing page fails the build rather than rendering nothing.
Two rules make templates safe to swap:
- Templates render, pages decide. A template component takes typed props and returns markup; the page above it owns fetching, SEO and business logic. That keeps both designs on one code path and one set of head tags.
- Copy lives in i18n under a top-level key named for the template
(
aurora.hero.title), registered in themodulesarray ofclient/plugins/i18n.ts. Locale files are not auto-discovered.
aurora follows the first rule throughout, with one exception: its
pages/Checkout.vue still carries the ordering flow, because splitting a working
payment path is only worth doing with a backend on hand to place a test order
against. It is classic's script byte-for-byte with restyled markup, so the two
cannot drift apart.
classic's pages are lift-and-shifts of the original route components and keep
their own data loading. That was the point: moving a 500-line page unchanged is
far safer than rewriting it, and the design it renders is the one being replaced.
New templates should follow aurora, not classic.
Branding and colour mode, all from Admin → Settings with a
NUXT_PUBLIC_PORTAL_* environment fallback for a first boot (the admin value
wins once set):
| Setting | Env fallback | What it does |
|---|---|---|
portal.site.name |
NUXT_PUBLIC_PORTAL_SITE_NAME |
Site name for og:site_name, logo alt text and structured data. Empty shows the built-in name. |
portal.site.tagline |
NUXT_PUBLIC_PORTAL_SITE_TAGLINE |
Default meta description. |
portal.theme.default |
NUXT_PUBLIC_PORTAL_THEME_DEFAULT |
light, dark or system for a visitor with no saved choice. A visitor's own choice is kept in a color-mode cookie and rendered on the server, so there is no flash of the wrong theme. |
portal.theme.user_toggle |
NUXT_PUBLIC_PORTAL_THEME_USER_TOGGLE |
false hides the light/dark switch. |
portal.logo |
NUXT_PUBLIC_PORTAL_LOGO |
Logo for light backgrounds. |
portal.logo.dark |
NUXT_PUBLIC_PORTAL_LOGO_DARK |
Logo for dark backgrounds; falls back to the light one. |
portal.logo.mark |
NUXT_PUBLIC_PORTAL_LOGO_MARK |
Square icon-only logo for compact places. |
portal.favicon |
NUXT_PUBLIC_PORTAL_FAVICON |
Browser tab icon; an upload generates the whole icon set. |
Logos and the favicon can be uploaded in Admin → Settings → Branding or pasted
as URLs. portal.template and portal.theme.default accept only their listed
values; anything else is refused with INVALID_SETTING_VALUE.
Related operator settings, all optional and hidden when empty:
portal.contact.whatsapp, portal.contact.telegram, portal.contact.email,
portal.contact.phone, portal.chat.provider, portal.newsletter.action_url,
portal.legal.tax_id, and portal.social.{facebook,instagram,linkedin,youtube}.
The header app launcher is off unless portal.apps.enabled is true, because
the apps it links to only exist in a deployment that runs them. Each entry then
needs a URL of its own — portal.apps.{account,tasks,erp,hostpanel,sheets,mail,docs,calendar},
or the matching NUXT_PUBLIC_* variable — and entries without one are left out.
Every seeded setting is editable in Admin → Settings; the table takes one row at a time.
The plans page's comparison table is built from each product's specification lines, edited in Admin → Products → Specification. Plans line up by feature name, so the same line has to be spelled the same way across products; a plan that omits a line shows a dash rather than shifting its column. With no product specified, the table is not rendered at all.
Innovayse uses a pluggable provider model. Implement the relevant interface in Innovayse.Domain and register in Infrastructure:
| Interface | Purpose | Built-in Providers |
|---|---|---|
IPaymentGateway |
Payment processing | Stripe |
IRegistrarProvider |
Domain registration | Namecheap, NameAm |
IProvisioningProvider |
Hosting provisioning | cPanel/WHM, CWP |
See CONTRIBUTING.md.
Copyright (c) 2024 Innovayse. Licensed under the Business Source License 1.1. On 2028-05-15 this software will become available under the GNU GPL v2.0 or later.