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>, andhelm --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
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
.pemGitHub 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.
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.
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.
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:
Drafts are left alone until they are marked ready. Opening a draft is somebody thinking out loud.
The App needs two things added if it was created before previews existed. Under Permissions & events in its GitHub settings:
- Repository permissions → Pull requests → Read-only. GitHub gates an event subscription on the matching permission, so without this the App is subscribed to something it is never sent.
- Subscribe to events → Pull request.
Read-only is all it is: Kaisin is told a pull request exists and builds the commit it names. It never comments, labels, reviews or merges. Changing permissions sends an approval request to the account the App is installed on, which for your own account is one click.
Add Deployments: Read and write while you are there, if you want pull requests to show where the code is running. See below.
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:
- Publish a bucket and it is readable by anybody. That suits a site's images. Attachments are
given its address as
S3_PUBLIC_URL. - Expose the store's S3 API with every bucket left private, for an application that signs
upload and download links for the browser. Only signed requests get anywhere. Attachments are
given that address as
S3_PUBLIC_ENDPOINT.
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 Kaisin subdomain, under the domain this installation already answers for. Works straight away.
- Your domain, via Cloudflare. Kaisin adds it to your Cloudflare account and creates the records. For a domain not there yet, it shows the two nameservers to set at the registrar.
- Your domain, at another provider. You add the record Kaisin shows you.
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:
- Nothing is overwritten. A hostname that already points somewhere belongs to whoever pointed it, and Kaisin reports that rather than taking it.
- Everything it creates is stamped with a comment saying so, visible in your own dashboard.
- It removes only what it stamped. A preview closing takes its own record away and nothing else, and removing the token from Kaisin leaves every record exactly where it is — those are what make live applications reachable.
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:
- Expose the built-in registry on a hostname the other cluster can reach, with TLS and an account, and give Kaisin that address and account.
- Use an external registry — GHCR, Harbor, Docker Hub — under Settings → Connections and registry. Both clusters then reach it the same way and there is nothing to re-address.
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:
- It stands down while webhooks work. Deliveries arriving means GitHub is already saying everything a poll would find, so it drops to a fifteen-minute backstop. The moment they stop, the fast rate resumes on its own — nothing has to notice that webhooks broke, which matters because whatever breaks them happens somewhere else and announces itself to nobody.
- Asking about an unchanged repository is free. Requests carry the ETag of the last answer, and GitHub does not count an unchanged reply against the rate limit.
- It is one loop, not one per pull request. A repository costs one request for all of its open pull requests, whether there are three or three hundred, plus one per branch that deploys on push.
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
- The command line — watching a build and reading logs without the browser
- Networking — what reaches what, and refusing the rest
- Importing a cluster — taking on what was already running
- Agents — letting Claude, or anything that speaks MCP, do what you can
- Cloudflare Tunnel — reaching a cluster with no inbound route
- Operating Kaisin — backups, recovery, permissions, lost passwords