Installing Kaisin on your own server

This is the whole path from an empty VPS to a working platform: a cluster, Kaisin, TLS, and your first sign-in. Budget half an hour.

Kaisin is not multi-tenant SaaS. One installation belongs to one person or team and owns the cluster it runs in, so every value below is yours to choose — including the domain it lives at.


What you need

A server 4 GB RAM and 2 vCPUs is comfortable for Kaisin plus a handful of applications. 2 GB works if you are only trying it out. Builds are the memory-hungry part.
A domain One hostname for the panel (admin.example.com) and ideally a wildcard for your applications (*.apps.example.com).
A pull token Kaisin's chart and images are private packages on ghcr.io. Installing takes a GitHub token with read:packages on bluepawlabs. Step 3 uses it twice.
kubectl and helm On your own machine, not the server.

1. A cluster

k3s is a full Kubernetes distribution in one binary, and it ships Traefik — which Kaisin uses for routing — already configured. On the server:

curl -sfL https://get.k3s.io | sh -

Copy the credentials to your own machine:

scp root@your-server:/etc/rancher/k3s/k3s.yaml ~/.kube/kaisin-config
sed -i '' "s/127.0.0.1/your-server-ip/" ~/.kube/kaisin-config
export KUBECONFIG=~/.kube/kaisin-config
kubectl get nodes

One node in Ready is all you need.

Working with more than one cluster? Name the context explicitly rather than relying on whichever one is current. Deploying into the wrong cluster is easy to do and unpleasant to undo.

The two tools spell it differently, which is worth knowing before a copied command stops with Error: unknown flag: kubectl --context <name>, and helm --kube-context <name>.


2. Decide how traffic reaches it

Do this before installing. It determines two values, and getting it wrong produces a hostname that never resolves and a certificate that never issues — with no error message pointing at the cause.

Your server has a public IP (most VPS providers)

The straightforward case. Point an A record at the server and let Let's Encrypt verify over HTTP:

admin.example.com      A      203.0.113.10
*.apps.example.com     A      203.0.113.10

Ports 80 and 443 must be open. Set tls.solver=http01 — the default.

Your server is at home, behind CGNAT, or on a private address

A public name pointing at 10.x, 192.168.x or 172.16–31.x cannot be made to work. Resolvers and home routers discard those answers as DNS rebinding protection. The record is correct and the name still does not resolve, and no amount of certificate work changes it.

Use a Cloudflare Tunnel instead, with tls.solver=dns01-cloudflare so certificates are proved by a DNS record rather than by being reachable.

You only want it on your own network

Leave host empty and reach the panel by port-forward:

kubectl -n kaisin port-forward svc/kaisin-web 3000:3000

No DNS, no certificates, nothing exposed.


3. Install

Generate the encryption key first and store it somewhere you will still have in a year. It encrypts every stored credential — registry passwords, Git tokens, database passwords. Losing it does not stop Kaisin starting; it makes those credentials unrecoverable.

openssl rand -base64 32

Kaisin is not public software. The chart and the four images it references are private packages, so two things need the pull token: Helm, to fetch the chart, and the cluster, to pull the images. Without it the install stops on an error that reads as though the chart does not exist.

helm registry login ghcr.io -u <github-user> --password <token>

kubectl create namespace kaisin
kubectl -n kaisin create secret docker-registry ghcr \
  --docker-server=ghcr.io --docker-username=<github-user> --docker-password=<token>

Then, from the published chart:

helm install kaisin oci://ghcr.io/bluepawlabs/charts/kaisin \
  --namespace kaisin \
  --set host=admin.example.com \
  --set platform.defaultDomainSuffix=apps.example.com \
  --set [email protected] \
  --set-string encryptionKey='the-key-you-just-generated' \
  --set 'image.pullSecrets[0].name=ghcr' \
  --wait --timeout 10m

Every release pushes the chart alongside the images, and the chart carries the image tag it was built with, so a given chart version installs the images that were released with it rather than whatever latest means today. With no version asked for, that command takes the newest release. Name one to pin it:

  --version 1.1.21

Worth doing for anything you intend to keep: an upgrade is then something you decide on rather than something the next helm install does for you.

No registry values for what you build. Kaisin runs its own registry inside the cluster, on a 50Gi volume, and keeps the last ten images per application so it cannot grow without bound. The pull token is for Kaisin's own images and nothing else.

To push to a registry you already have instead:

  --set registry.mode=external \
  --set registry.server=ghcr.io \
  --set registry.repositoryPrefix=your-github-user \
  --set registry.username=your-github-user \
  --set-string registry.password=$GITHUB_TOKEN

The values worth understanding:

Value Why
host Where the panel is served. Yours to choose — nothing assumes a domain.
platform.defaultDomainSuffix Only used to suggest hostnames: an application called storefront is offered storefront.apps.example.com. With a wildcard record, new applications need no DNS work at all.
tls.acmeEmail Creates the ACME account for your installation, used only for expiry notices. Without it, domains are served over plain HTTP.
registry.storage Size of the volume images are kept on, when Kaisin runs its own. Ten images of a typical Node application is a few gigabytes; 50Gi is comfortable.
platform.imageRetention Images kept per application. What is running is never removed whatever this says, and neither is anything a recent deployment could be rolled back to.

cert-manager is installed for you. If the cluster already has one, pass --set certManager.install=false; Kaisin checks and refuses to install over an existing one rather than breaking it.


4. First sign-in

A fresh installation has no administrator. Open https://admin.example.com and it asks you to create one, after proving you control the cluster:

kubectl -n kaisin get secret kaisin-setup -o jsonpath='{.data.token}' | base64 -d

Paste that, choose your email and password, and you are the owner.

The token exists because the panel is reachable from the internet before it has any account, and Kaisin can deploy containers into your cluster — so an ungated setup page would let whoever found it first take the cluster. Needing to read a Secret means only someone with cluster access can complete setup.

Nothing is printed to a log, deliberately: log lines vanish when a pod is replaced, and a password that only ever existed in one is a lockout waiting to happen.


5. Deploy something

  1. Settings → Connections — press Create the GitHub App. GitHub creates an App belonging to you, hands Kaisin its key once, then offers to install it on the accounts whose repositories you want to deploy.

    This is not a token you paste. The App reaches only the repositories you grant it, and its tokens expire within the hour. Push webhooks come with it, so deploy-on-push works without registering a webhook per repository.

    What it asks for, and why:

    Permission Used for
    Contents, metadata: read Cloning what it builds.
    Pull requests: read Being told a pull request exists, which is what a preview environment starts from.
    Deployments, checks: write Its own record of its own deployments, on the commit and the pull request.
    Contents, pull requests: write Asked for, not required. Only used to propose a change to an imported application's chart: a branch of Kaisin's own, one file on it, and a pull request from it.

    It never pushes to a branch it did not create, and it merges, reviews, labels and comments on nothing.

    Kaisin must already be on HTTPS: GitHub will not call back to plain HTTP, and the webhook it is given would never be delivered.

    Already have an App? Under I already have a GitHub App on the same screen, give its ID and the .pem GitHub downloaded when the key was generated. Kaisin checks the key against GitHub before storing anything — it has to authenticate as that exact App, and the App has to already carry the permissions and events Kaisin needs, each gap named rather than discovered later as a deployment that never reports. Two things stay yours to set on the App afterwards, and Kaisin shows both with this installation's hostname filled in: the webhook URL and the setup URL. It also makes a webhook secret if the App has none, and shows it once.

    Worth knowing when this is the route you need: an organisation can forbid creating Apps from a manifest, and an App already installed across an organisation's repositories is not something anyone wants to create a second time.

  2. New application — pick a repository and branch. Kaisin detects the framework and shows you the build configuration it intends to use, which you can edit.

  3. Deploy. The build runs as a Kubernetes Job in its own namespace using rootless BuildKit; no Docker socket, no privileged container, and no repository code running inside Kaisin.

  4. Domains — add a hostname. Kaisin shows the address to point it at and the record type to use, and tells you whether DNS actually resolves there yet.

Deploying an existing image instead of building from source works the same way — choose Deploy an image in the wizard.

Preview environments

A pull request can have an environment of its own: its own namespace, a build of the exact commit under review, and its own hostname. It is removed when the pull request closes.

Turn it on per application, under Settings → Give every pull request its own environment. It is off by default, because it costs a build and a running copy for every open request and that is not something to acquire by surprise on a busy repository.

Each preview gets a hostname of its own, derived from the application and the request — storefront-pr-118.apps.example.com — under the domain this installation already serves. That needs a wildcard record pointing at the cluster (or at your tunnel), which is the same one-time setup that makes every other application reachable without touching DNS again. Without it Kaisin still creates the hostname and reports it as not resolving, which is at least something to act on.

A preview brings what the application leans on. It gets empty databases of its own for the ones its target environment attaches, and a route or a link that names another application points at that application's preview rather than at production.

Three things worth knowing:

Preview environments never touch the ones you made deliberately. Kaisin will delete an environment on its own initiative only when it created it for a pull request — which is recorded on the environment, not inferred from its name, so an environment somebody happened to call pr-42 is safe.


Deployments on the pull request

Kaisin records each deployment on GitHub, so a pull request says kaisin deployed to preview/pr-118 with a working link, and the repository's Environments list shows what is running where. Nobody has to open Kaisin to answer "is this up yet, and at what address".

It needs Repository permissions → Deployments → Read and write, and Checks → Read and write to put the same fact in the list of checks, which is where people look to decide whether a pull request is ready. What it writes is its own record of its own deployments: the commit it deployed, the environment it went to, whether that worked, and the URL. A check is Kaisin's own row and nothing more; it cannot see, rerun or affect anybody else's. An App created before these existed needs them added by hand, the same way as pull requests above; without them Kaisin deploys exactly as before and says nothing on GitHub.

Environments are named for what they are: production, staging, and preview/pr-118 for a preview — named per pull request so two open requests do not take turns marking each other's deployment inactive. When a pull request closes and its environment goes, the deployment is marked inactive rather than left pointing at a hostname that stopped answering.

Only deployments of a commit are announced. Changing a variable redeploys the same code, and putting that on a pull request would report a deployment nobody caused.


Building in your own CI

Kaisin builds on the cluster by default. An application can hand over only the build instead: set its build method to Built by GitHub Actions and Kaisin goes on deciding everything else. The push still queues a build and the preview is still created; the repository's workflow claims the build at POST /api/ci/builds/claim, builds what the claim describes — the root, the Dockerfile, the build arguments, the image — and reports the digest to POST /api/ci/builds/{id}/result. Kaisin rolls it out.

There is no secret to store. The workflow's GitHub OIDC token, with the audience kaisin, is the credential, and it reaches only builds of its own repository.


Object storage

Alongside PostgreSQL and Redis, Kaisin can provision S3-compatible object storage, run by Silo — the maintained fork of MinIO — on a volume in your cluster. It is the third thing most applications reach for, and the one that otherwise sends a self-hosted installation back to somebody else's cloud for the sake of a file upload.

Create it like any other resource, choosing Object storage. Kaisin runs it, generates the root credentials, and keeps them encrypted.

Buckets and files are in the panel. The store's own page makes buckets and browses, uploads and removes what is in them, through the API under your own session, so the root credentials stay in the cluster. Lifecycle rules, replication, versioning and policies are still the store's own console, which does them better than a panel would. Press Connect on the resource for the credentials and the command to reach it:

kubectl -n kaisin-<project>-<environment> port-forward svc/<name> 9001:9001

Then open http://localhost:9001 and sign in with the access key and secret shown.

Two ways out of the cluster, both a hostname with a record and a certificate:

Attaching it to an application injects a family of variables rather than one string, because that is what an S3 client is configured with. With the default name S3:

Variable
S3_ENDPOINT The in-cluster address, e.g. http://uploads.kaisin-shop-production.svc.cluster.local:9000
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY The generated credentials
S3_REGION us-east-1, which is what every S3-compatible server answers to
S3_FORCE_PATH_STYLE true. A bucket is not a subdomain here

The unprefixed AWS names are deliberately not used. They are what an SDK picks up with no configuration at all, which is convenient exactly once and then silently wrong the moment an application talks to two buckets, or to this and to something at AWS.

It is one node on one volume. That is single-drive mode, and it is not replicated: what it replaces is a bucket somewhere else, for an installation whose whole premise is that the data stays on hardware its owner can point at. Durability comes from the volume and from backing it up, exactly as it does for the database beside it.


Letting Kaisin manage DNS

Adding a domain is one step in Kaisin and two in your DNS provider, and the two outside are the ones that get forgotten. Give Kaisin a Cloudflare token under Settings → Domains and DNS and it does all three: the record, and — behind a tunnel — the route on the connector.

It matters most for the hostnames nobody is there to create. A pull request that opens at nine o'clock gets a preview environment, and a preview nobody can open answered no question.

Create a custom token at Cloudflare → API Tokens with:

Permission What it buys
Zone → DNS → Edit on the zones you serve The record. Required.
Zone → Zone Settings → Edit Reading and setting how Cloudflare terminates TLS, so Kaisin can tell which end holds the certificate.
Zone → Zone → Edit Adding a domain to your Cloudflare account from Kaisin. See below.
Account → Cloudflare Tunnel → Edit The route. Required if you are reached through a tunnel; without it Kaisin creates the record and the connector answers 404.
Account → Access: Apps and Policies → Edit Opening the two paths GitHub has to reach when Cloudflare Access guards the panel. Optional; the tunnel guide says which.

Kaisin checks the token before storing it and says which of these it has, so a missing permission is named rather than discovered later on a preview that will not open. It rechecks the token itself afterwards.

Each hostname is asked how it will resolve, where it is typed, because each answer leaves somebody a different job:

A hostname can also do less than serve an application. One can only redirect, one can sit under a path so two applications share it, and one can hand a path or a whole subdomain to another application in its project.

Three rules it holds to, because this is your account and not Kaisin's:

A wildcard record still works and needs no token. The difference is that a wildcard cannot cover your apex, and it gives every name the same answer whether Kaisin is serving it or not.


Screenshots

After an application becomes healthy, Kaisin photographs it: a short-lived Job runs a headless browser against it, posts the picture back, and the panel shows it beside the application. It answers in a glance what a page of status fields cannot — whether the thing that deployed is the thing you meant — and for a pull request preview that is the entire question.

Nothing to configure. The browser looks at the public address when there is one that resolves here, because that is what a visitor sees; otherwise it uses the Service inside the cluster, so a preview with no domain yet still gets a picture. The Job carries no service account, drops every capability, and is deleted five minutes after it finishes. A capture that fails costs one missing thumbnail and is retried by the next deployment.

Projects can carry a logo too — PNG, JPEG or WebP, up to 512KB, uploaded from the project's own screen. SVG is refused: it can carry script, and it would be served from the same origin as the panel. The type is read from the file's own bytes rather than from what the upload claimed.


More than one cluster

Kaisin builds on the cluster it runs in and, unless told otherwise, deploys there too. Registering another separates the two: the grunt work stays where the registry and the build cache already are, and production runs somewhere chosen for running production.

Register it under Settings → Clusters: a name, a kubeconfig, and how that cluster reaches the registry. Kaisin checks the connection before it stores anything — a cluster it cannot reach is not a cluster anything can be deployed to, and finding that out now beats finding out during a deployment. The kubeconfig is encrypted with this installation's key and never shown again, so give it an account that can manage namespaces, Deployments, Services and routes, and nothing more.

Then, on a project, set which cluster each environment runs on. Applications follow their environment, so the decision is made once:

production  → Production cluster
staging     → This cluster
pr-118      → This cluster

The registry is the part that does not travel. The built-in registry answers on a NodePort at loopback, which is exactly as local as it sounds — a node in another cluster cannot pull from it. So a registered cluster says how it reaches the registry, and Kaisin re-addresses each image for it and plants the credentials as a pull secret. You have two ways to satisfy that:

Only images from your own registry are re-addressed. An application deploying a public image keeps pointing at where that image actually lives.

Moving an environment redeploys its applications on the new cluster and removes the namespace they were in, so there is one copy rather than two. It is refused while the environment has a managed database: Kaisin can redeploy an application anywhere, because the image is the same and the state is not in it, but a volume is the state and there is no honest way to move one between clusters behind your back.

Builds always run on the cluster Kaisin runs in. Preview environments do too, whatever production does — a copy of a branch that lives for a few days belongs where the spare capacity is.


Things that actually go wrong

A deployment on another cluster sits in ImagePullBackOff. The image address is one that cluster cannot reach. Check Settings → Clusters — the registry address there is how that cluster asks for images, and localhost:30500 is only ever right for the cluster the registry is in.

Nothing happens on push or on a pull request. Kaisin polls GitHub every minute as well as listening for webhooks, so this should recover on its own within a minute or two. If it does not, look at Settings → Connections: Kaisin lists GitHub's own record of what it tried to send here. Deliveries answered with a redirect never reached Kaisin at all — something in front of the panel replied instead, and in practice that is an access gateway such as Cloudflare Access protecting the webhook address along with everything else. GitHub cannot sign in to it.

Let /api/webhooks/ through without authentication. In Cloudflare Access that means a second application covering admin.example.com/api/webhooks with a single Bypass · Everyone policy; Access matches the more specific path first, so the panel stays protected. This is safe by design: the endpoint verifies GitHub's HMAC signature on every request and rejects anything else, which is exactly what the signature is for — and the panel's own sign-in is unaffected.

Once it is through, use Resend next to a failed delivery rather than closing and reopening the pull request.

You do not have to fix it at all if you would rather not: polling covers the same ground a minute later. Webhooks are the difference between deploying in seconds and deploying within the minute.

Polling is much cheaper than it sounds, and it gets out of the way when it is not needed:

A pull request opens and no preview appears. Almost always the App: it keeps the permissions of the day it was created, and GitHub warns nobody. Kaisin checks now — Settings → Connections says so plainly, and so does the application's Settings tab when previews are on but the App cannot hear about pull requests. It takes both the Pull requests: Read-only permission and the Pull request event subscription; GitHub sends the event only when the permission is there.

The hostname does not resolve. Check whether it points at a private address. See step 2; this is the single most common cause and it looks like a Kaisin fault.

InvalidImageName or ImagePullBackOff. The registry credentials are wrong, or the image is private and the pull secret has expired. Check Settings → Connections.

An application starts and immediately fails. Many public images expect to run as root — nginx, redis, postgres, much of Docker Hub. Kaisin runs applications as a non-root user with all capabilities dropped. Use the "allow root" setting on the application, which also restores the small capability set such images need. Kaisin inspects the image first and warns you before deploying, rather than after.

An image that is not root is refused as though it were. Some images name their user instead of numbering it — USER gotenberg rather than USER 1001 — and Kubernetes cannot check a name against root without running the image. Give the number under Settings → Advanced → Run as user ID and Kaisin starts it as that user with nothing else relaxed. docker run --rm --entrypoint id <image> prints it. Allowing root also starts such an image, and grants far more than it needs.

Certificates never issue. Confirm tls.acmeEmail is set — no email means no ACME account, which means no issuer and no certificate. Then check kubectl -n kaisin describe certificate.

Upgrades behave strangely. Do not use --reuse-values. It carries forward every value from the previous revision, including an image tag pinned during a repair or a setting changed while debugging, and produces a cluster that disagrees with the chart for reasons the release history does not explain. Pass values explicitly, or use --reset-then-reuse-values.


Upgrading

The install command, with upgrade in place of install:

helm upgrade kaisin oci://ghcr.io/bluepawlabs/charts/kaisin \
  --namespace kaisin \
  --set host=admin.example.com \
  --set platform.defaultDomainSuffix=apps.example.com \
  --set [email protected] \
  --set 'image.pullSecrets[0].name=ghcr' \
  --wait --timeout 10m

No version asked for means the newest release, the same as installing. No encryption key either: the chart looks up the one already in the cluster and keeps it. The pull secret is named again because values are passed whole, and Helm's own login to ghcr.io has to still be good — a token that expired since the install fails here the way a missing one did there.

Nothing here names an image tag, and nothing should. The chart version is the version — it carries the images it was released with, so a chart that installs is a set of images that were tested together. Pinning image.tag by hand produces an installation whose Helm release and running code disagree, and every later upgrade inherits the disagreement. Pin the chart instead, with --version 1.1.23, if you want to choose the moment.

The API applies migrations on start; the controller and worker wait for the schema. The encryption key, the database password and the setup token all survive upgrades.

Uninstalling

helm uninstall kaisin --namespace kaisin

Secrets marked resource-policy: keep — the encryption key and the database password — survive deliberately, so an accidental uninstall does not destroy stored credentials. Remove the namespace to remove those too. Applications Kaisin deployed live in their own namespaces and are not touched.


Where to go next