GitHub - codeblocktz/yacht: A self-hosted PaaS for Kubernetes. Deploy to your own server with real Kubernetes underneath, on a $5 VPS. · GitHub
Skip to content
codeblocktzPublic

About

A self-hosted PaaS for Kubernetes. Deploy to your own server with real Kubernetes underneath, on a $5 VPS.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

Yacht

A self-hosted PaaS for Kubernetes. Deploy your app to your own server, with real Kubernetes underneath rather than a Docker wrapper, and an all-Go control plane you can read in an afternoon.

Runs on K3s, so a $5 VPS is enough to start.

Warning

Not ready for production. Yacht is under active development. Interfaces, the database schema, and behaviour all still change without notice, there has been no tagged release yet, and none of it has run anywhere long enough to have earned your trust.

Run it on something you can afford to lose, and keep backups you have actually restored from. Do not put it in front of anything whose downtime or data loss would matter.

What it does today. You can deploy from a container image or from a Git repository, give it a domain, attach storage, and watch it from the dashboard. See What works today.

Why

Existing self-hosted PaaS options mostly wrap Docker Compose or Swarm. That works until you want health probes, rolling updates, or a second node — at which point you are fighting the abstraction instead of shipping.

Kubernetes already solves those problems. K3s makes it small enough to run on a single cheap box. Yacht is a control plane over it that stays out of the way: every workload is an ordinary Deployment, so kubectl keeps working and nothing you build here is locked in.

What works today

Shipping an app

Deploy a container image, with env vars and replicas ✅
Change the image, port, limits and repository afterwards ✅
Build and deploy from a Git repository, using buildpacks and an external registry ✅
Branches read from the remote as you type ✅
Deploy on push, from GitHub, GitLab, Gitea or Forgejo webhooks ✅
Builds run as an isolated Job, with the log kept ✅
Build output streamed to the page while it runs ✅
Push build output to an operator-configured OCI registry ✅
Scale, redeploy, delete ✅
Liveness and readiness probes ✅
Deployment history, with a log per deployment ✅
Roll back to an earlier release, settings and all ✅
App logs, and per-request HTTP logs you can search and page ✅
Live logs, streamed as the container writes them ✅
Persistent volumes, mounted and expandable ✅
Secrets sealed at rest, kept out of the app record ✅
Deploy a wired stack from a template in one action ✅
Apps that sleep when idle and wake on the next request ✅

Reaching it

A hostname per app, served the moment it starts ✅
Custom domains people bring, proven continuously in the background ✅
A domain that stops resolving is noticed and withdrawn ✅
TLS from one shared wildcard certificate, for platform hostnames ✅
A certificate of its own for every brought domain, from Let's Encrypt ✅

A brought domain cannot be covered by the platform wildcard, so each one is issued its own certificate through cert-manager, which the installer sets up. Issuance is HTTP-01: public port 80 has to reach the cluster, and must not be redirected to HTTPS cluster-wide. The domain's page shows the certificate arriving, when it expires, and — if it does not arrive — what to check. An install without cert-manager serves brought domains over plain HTTP, and says so on the domain rather than leaving the browser to.

An app can sleep when it has had no requests for a while — 30 minutes by default, never under 5 — set per app or as a team default, and off until somebody turns it on. It is scaled to zero, its hostnames are routed to the engine's waker, and the next request wakes it: an API call is held until the app answers it, a browser sees a small waking-up page that reloads itself. Idleness is read from Traefik's per-service request counters (its k3s chart publishes them by default), so nothing sleeps where requests cannot be counted. The cluster has to reach the waker: set YACHT_WAKER_ADDR to this node's address and a port open to the cluster's pods. A sleeping app's CPU and memory are sold to others, less a wake reserve the capacity policy keeps so that a share of the sleepers can wake at once.

The installer defaults to Let's Encrypt's staging environment, whose certificates browsers do not trust. Re-run it with --acme-environment production --acme-email you@example.com once you are ready.

Running the cluster

Live workload status read from the cluster ✅
Cluster view — nodes, pods, volumes, events, utilisation ✅
Per-team quotas on apps, CPU, memory and storage, set by the operator ✅
Act as a team for support — named operators only, re-checked every request, bannered on every page, recorded ✅
Capacity — the cluster's room beside what every team has committed ✅
Add a node, then cordon, drain, or remove one ✅
Namespace provisioning with enforced security posture ✅
A project canvas apps can be arranged on ✅

Accounts and foundations

Magic-link sign-in, sessions, sign-out everywhere ✅
Optional password, added and removed from the account page ✅
Teams with Owner / Admin / Member and invitations ✅
Identity seam — single owner, bearer token, or session ✅
Dashboard with pluggable chrome, light and dark ✅
Postgres schema + embedded migrations ✅

Utilisation percentages need metrics-server in the cluster. Without it everything else still works and those figures read — rather than zero.

Install on a VPS

Not installable yet. This repository is public and the Pages workflow is configured to publish only scripts whose exact revision passed CI. However, there is no tagged release yet, so the installer has no release assets to download. Do not pipe the URL below into a shell until a release is announced; until then, use Quick start.

curl -sSL https://codeblocktz.github.io/yacht/install.sh | sudo sh

Debian or Ubuntu, amd64 or arm64. It installs K3s, Postgres, and Yacht as a systemd service, then prints the dashboard URL and a token to sign in with. About ninety seconds on a fresh box.

To install a binary you built — from source, or an application wrapping the engine — instead of a release, pass it with --binary (after sh -s -- when piped). It must be a Linux build for the machine's architecture.

To upgrade later:

curl -sSL https://codeblocktz.github.io/yacht/upgrade.sh | sudo sh

That one only swaps the binary and restarts. If the new version does not come up healthy it puts the old one back, so a bad release costs a restart rather than an outage.

Binary /usr/local/bin/yacht
Config /etc/yacht/yacht.env
Service systemctl status yacht
Logs journalctl -u yacht -f

Back up /etc/yacht/yacht.env. It holds YACHT_SECRET_KEY, which seals your stored secrets and cannot be regenerated — losing it loses them. Re-running the installer preserves it, and every other generated value, on purpose.

The installer serves the dashboard over plain HTTP, so the token crosses the network in the clear. Set YACHT_BASE_URL and YACHT_APP_DOMAIN in the config and put it behind TLS before you rely on it; until then ssh -L 8080:127.0.0.1:8080 is the safer way in.

Both scripts are worth reading before you pipe them to a root shell — install.sh and upgrade.sh are exactly what those URLs serve, published straight from this repository.

To remove it all:

sudo systemctl disable --now yacht
sudo rm -rf /etc/yacht /etc/systemd/system/yacht.service /usr/local/bin/yacht
sudo /usr/local/bin/k3s-uninstall.sh          # only if you want the cluster gone too
sudo -u postgres dropdb yacht && sudo -u postgres dropuser yacht

Quick start

For development, or to run against a cluster you already have.

Requirements: Go 1.26.6+, Postgres, and a kubeconfig pointing at a cluster.

git clone https://github.com/codeblocktz/yacht.git
cd yacht

export YACHT_DATABASE_URL="postgres://yacht:yacht@localhost:5432/yacht?sslmode=disable"
export YACHT_KUBECONFIG="$HOME/.kube/config"
export YACHT_AUTH_TOKEN="$(openssl rand -hex 24)"   # omit only on a trusted network

make run

Then open http://localhost:8080.

Migrations run automatically at startup. If the cluster is unreachable Yacht still boots and says so on the overview page, so you can fix your kubeconfig without digging through logs.

Deploying an existing container image needs no registry configuration. Building from a Git repository does: Yacht does not run a registry, so first provide an external OCI registry and push credential under Admin → Registry. For private registries, a token service on an unrelated registrable domain is untested and unsupported in this main-based version; current main does not proactively reject that topology. The manifest-resolution integration will enforce the stricter boundary: token services use the registry authority for plain HTTP, or the same registrable domain over TLS. Every K3s node must also be configured separately to pull from an insecure registry.

Configuration

Variable Default Notes
YACHT_DATABASE_URL — Required. Postgres connection string
YACHT_ADDR :8080 Listen address
YACHT_KUBECONFIG $KUBECONFIG Path to a kubeconfig
YACHT_KUBE_IN_CLUSTER false Use the mounted service account instead
YACHT_AUTH_TOKEN — Bearer token. Unset, and with no accounts, means no authentication
YACHT_OWNER_ID owner-local The team every resource belongs to on a fresh install
YACHT_OWNER_EMAIL — The one address that may sign in before anybody has an account
YACHT_OPEN_SIGNUP false Anybody may make an account: a stranger is mailed a link that creates it, with a team of their own. Requires YACHT_OPERATORS, YACHT_OWNER_EMAIL and a mail transport
YACHT_APP_DOMAIN — Apps get <name>.<this>. Point *.<this> at the cluster
YACHT_WILDCARD_TLS false Serve those hostnames from the controller's default certificate
YACHT_NETWORK_ISOLATION team Keep teams apart on the network: team refuses connections between teams' apps; strict also keeps apps and builds off private addresses; off writes no policy
YACHT_EGRESS_ALLOW Private ranges, as CIDRs, strict isolation still lets apps and builds reach
YACHT_OPERATORS — Emails of the people who run the install — the Admin area: teams and their quotas, capacity, nodes, cross-team views, registry, DNS. Empty means each team's owner — set it once an install hosts several teams. Only named operators may act as a team for support; taking an address off the list ends that person's impersonation on their next request
YACHT_CERT_ISSUER — cert-manager ClusterIssuer that gives each custom domain its own certificate. The installer sets yacht-acme
YACHT_BASE_URL — Public URL. Setting it switches sign-in on
YACHT_SMTP_ADDR / YACHT_RESEND_API_KEY — How sign-in links are delivered. Neither means they go to the log
YACHT_WAKER_ADDR — ip:port the cluster reaches the waker at. Setting it lets idle apps sleep. The waker listens on its own port, never the dashboard's
YACHT_WAKE_TIMEOUT 60s How long a wake may take before the app goes back to sleep
YACHT_DEBUG false Verbose logging

The full list, with the reasoning behind each, is in .env.example.

Wrapping the engine

Yacht is an engine with four seams — orchestrator, identity, dashboard chrome, notifications — and package engine is its public surface. An application built on it composes the same engine with overrides rather than a fork:

cfg, _ := engine.LoadConfig()
engine.Run(ctx, cfg, engine.Overrides{
	Slots:    myChrome{},              // brand, header tools, banner, extra nav
	Identity: myOrganisations,         // who a request acts as
	Extra:    engine.ExtraRoutes{Owner: mountBilling},
})

Everything commercial belongs in the wrapper: make verify refuses a tenant, subscription, invoice or wallet declared in the engine.

Two things in the chrome are worth knowing. Slots.BrandMark replaces Yacht's mark beside your BrandName (templ.NopComponent for a wordmark alone). And while an operator is acting as a team, the engine draws its acting notice above whatever Slots.Banner you set — it composes with your banner and cannot be replaced by it. Surfaces.ActingAs says whether a request is acting, for deciding what else to show. A wrapper that builds its own identity on Accounts.Provider passes engine.OperatorCheck(cfg.Operators) to WithActingCheck to keep impersonation; without it, no session acts as anything.

Sleeping is driven through Engine.Apps like quotas are: SetTeamSleepDefault(ctx, team, engine.SleepPolicy{Enabled: true, After: 30 * time.Minute}) turns it on for a plan's teams, SetSleepSetting and SleepStatus override and read one app, and SleepNow / Wake do it by hand. For metering, an app asleep has App.RunningReplicas() of zero, and SleepIntervals(ctx, team, from, to) with engine.AwakeWithin says how long each app was awake in a window — charge replicas × awake time. Serve runs the waker beside the dashboard; a wrapper serving its own handler serves Engine.Waker() on Config.WakerListenAddr() too, and Overrides.WakerBrand names it on the waking-up page.

Security posture

Workloads are hardened by construction, not by configuration. Every namespace Yacht creates is labelled for Pod Security Admission at restricted and gets a default LimitRange; every pod runs with:

  • runAsNonRoot, allowPrivilegeEscalation: false, privileged: false
  • all Linux capabilities dropped
  • seccompProfile: RuntimeDefault
  • a read-only root filesystem, with a writable /tmp so that stays practical
  • no service account token mounted

There is no API for privileged containers, host networking, or host paths — not as an omission to be filled in later, but because a request for them has nowhere to go. Images that genuinely need a writable root filesystem have one explicit, visible escape hatch (WritableRootFilesystem).

An important consequence: images that run as root will not start. That is the intended behaviour. Most official images already ship a non-root user.

Network isolation

Every app's namespace also gets a NetworkPolicy. It accepts connections from the same team's apps and from the platform — the ingress controller, the engine's waker, anything in a namespace Yacht did not make for a team — and refuses them from every other team's apps and from builds, which run customers' Dockerfiles. Nothing connects to a build at all.

YACHT_NETWORK_ISOLATION=strict limits the way out as well: apps and builds reach DNS, their own team's apps and public addresses, and no private address — not the nodes' network, not cloud metadata, not anything else on the LAN — except ranges named in YACHT_EGRESS_ALLOW. Use it wherever teams are strangers to each other. Namespaces made before a change of mode are brought into line at startup.

A wrapping application can change the mode while the engine runs, with Engine.SetNetworkIsolation: every namespace is rewritten before it returns, and a deploy racing the change never leaves one on the old mode. The settings are then only where it starts. With several replicas, the wrapper keeps the setting where each replica reads it, and calls this on every replica.

This needs a network plugin that enforces NetworkPolicy. K3s's does, through its embedded policy controller; flannel alone does not.

Signing in

An emailed link is how everybody gets in first, and it never stops working. A password is optional and additive: it is added from Account, and from then on either one opens the same session.

  • Stored only as an Argon2id hash with a per-row random salt, with the cost parameters inside the stored string — so raising them later re-hashes people as they sign in rather than resetting anybody.
  • A minimum of 12 characters and no composition rules. Every such rule is a constraint an attacker subtracts from the search space, and in practice they produce a shorter password with a digit on the end.
  • Sign-in never reveals whether an address is registered or whether it has a password. An unknown address, an address with no password, and a wrong password produce the same message, the same status and the same cost.
  • Five attempts per address and twenty per client every fifteen minutes, counted separately from the link's own budget — so guessing at somebody's password can never lock them out of the link they rely on. There is no account lockout: on a known address that is a denial of service, and the most valuable address on any install is the one in the operator's .env.
  • Adding, changing or removing a password needs either the current password or a sign-in from the last ten minutes. Changing or removing one signs out every other browser; adding a first one signs out nothing.
  • Removing it is allowed, because the emailed link never stopped working.

Forgotten it? Ask for a sign-in link, follow it, and set a new one from Account. There is no separate reset link, deliberately: a magic link already goes to the same mailbox and grants strictly more, so a second kind of token would add a table, four routes and four more expiry rules without adding any security.

No password is ever logged, and none is rendered back into a page.

Architecture

Yacht is built to be wrapped. Anything that needs to differ for a hosted, multi-tenant deployment goes through one of three seams, so a larger application can build on this module rather than fork it:

Seam Interface Engine ships A wrapper supplies
Orchestration orchestrator.Orchestrator single cluster multi-cluster placement
Identity identity.Provider single owner, bearer token tokens resolved to an account
Dashboard chrome web.SlotProvider plain navigation account switcher, usage, billing

Two rules keep the seams honest:

  1. No Kubernetes types cross the orchestrator boundary. Callers never import client-go, and a non-Kubernetes backend stays possible.
  2. Every table carries owner_id, and unique constraints are scoped by it. The engine writes one value there forever. It exists so that scoping is a cheap indexed predicate rather than a join added later — a predicate is a check that gets written, a join is a check that gets skipped.
cmd/yacht             entrypoint and wiring
internal/app          workload lifecycle — keeps database and cluster agreeing
internal/account      people: users, teams, roles, invitations
internal/cluster      how a machine joins this cluster
internal/config       environment configuration
internal/domain       hostnames — the one we issue, and the ones people bring
internal/notify       delivers messages to people
internal/registry     external image-registry settings and credentials
internal/secret       values that must survive a database dump
internal/identity     SEAM 2 — who owns this request
internal/orchestrator SEAM 1 — the runtime contract
          └── k8s     Kubernetes implementation
internal/store        schema, embedded migrations, sqlc queries
internal/web          SEAM 3 — dashboard, slot-based layout

The database is the source of truth for which apps exist; the cluster is the source of truth for how they are doing. Neither is asked the other's question, which is why listing apps never enumerates Deployments and why status is never cached in a column that can go stale.

Development

make assets     # templ codegen + Tailwind
make check      # vet + database-backed race tests
make verify     # every local CI/release gate, including the gallery
make build
make dev        # rebuild and run

make check requires YACHT_TEST_DATABASE_URL so owner-scoping and other database security tests cannot silently skip. For an intentionally partial run, opt out explicitly with YACHT_ALLOW_DATABASE_TEST_SKIPS=1 make check.

templ and sqlc are Go tool dependencies, and make css downloads the Tailwind standalone CLI — a single binary, no Node or npm. A full make verify also requires PostgreSQL, YACHT_TEST_DATABASE_URL, and shellcheck.

Generated *_templ.go files and the compiled app.css are both committed, which keeps plain go build working for anyone who has not run codegen. CI rebuilds both and fails on drift.

Design system

The UI uses the templUI / shadcn token set — the same CSS custom properties, so a component copied in with templui add <name> inherits this theme unchanged. Deliberate departures:

  • Monochrome primary. A saturated primary button reads as consumer software. Colour is reserved for state, where it carries information.
  • Pure neutral greys and Inter. Chroma-zero greys, so the only colour on a page means something, and Inter bundled in the binary (no font CDN), with tabular figures wherever numbers line up.
  • Cards on a canvas. The content column sits on a soft canvas; panels, tables and forms are white rounded-xl cards with a hairline and no shadow. Controls and nav rows are rounded-lg, at 8px radius.
  • Denser than stock where it counts. 14px prose and controls, 13px table rows, h-8 controls, and a square readout strip (.metrics) instead of a grid of stat cards.
  • Status is a dot and a word in a soft pill of its own colour, tinted from currentColor so a tone means the same as text, dot and fill.
  • Shared page parts. .page-head / .page-title / .page-sub, .panel, .form-row, and the list .toolbar, .filters and .pager are classes rather than templates, so an application wrapping the engine draws its own pages with the same parts.

Light and dark both ship, resolved before first paint so there is no flash on navigation.

Visual states

Every state the dashboard can be in — degraded workloads, failed deploys, a node at 98%, an unreachable cluster — renders from a gallery, without needing a cluster or a database:

YACHT_GALLERY_OUT=/tmp/gallery go test ./internal/web -run Gallery

Those are the states that silently rot, because nobody sees them until a customer does. The generated gallery can be reviewed directly from its output directory.

Tests use the client-go fake clientset, so the full orchestration path — namespaces, security context, apply idempotency, status — is verified without a cluster.

Contributing

Issues and pull requests are welcome. There is no CLA; contributions are licensed under MIT, the same as the project.

CONTRIBUTING.md covers getting set up, what CI enforces, and the conventions this codebase holds to. One thing worth knowing before your first test run: database-backed tests skip themselves without a DSN, so make check refuses to start until YACHT_TEST_DATABASE_URL is set or you explicitly opt in to a partial run.

Found a vulnerability? Please do not open an issue — see SECURITY.md. Participation is covered by the Code of Conduct.

License

MIT — see LICENSE.

About

A self-hosted PaaS for Kubernetes. Deploy to your own server with real Kubernetes underneath, on a $5 VPS.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages