Push to GitHub, get a website. No Dockerfile needed. Tooling and Flux manifests that build your Node / Python / PHP apps with Cloud Native Buildpacks and roll them out into a Kubernetes cluster automatically.
π¦ git push Β βΒ ποΈ kpack Β βΒ π ghcr.io Β βΒ π Flux Β βΒ π https://your.site
A small toolkit on top of an existing Flux-managed cluster (referred to below as your kubernetes-config repo) that turns "a GitHub repo with a web app" into "a running,
auto-updating website" with one command.
| Piece | Role | |
|---|---|---|
| ποΈ | kpack | watches each app repo and builds an immutable, digest-pinned image with Paketo buildpacks on every commit, pushing it to ghcr.io |
| π | Flux image automation | scans ghcr for new builds and commits the newest tag back into the app's HelmRelease |
| π’ | bjw-s/app-template |
generic Helm chart that runs the app: Deployment, Service, Traefik Ingress |
| π§° | just new-website |
generates all manifests for a new site, optionally with a bundled MariaDB |
git push (app repo)
βββΆ kpack Image builds via buildpacks βββΆ ghcr.io/<owner>/<app>:b<N>.<timestamp>
βββΆ Flux ImageRepository + ImagePolicy pick the highest build number
βββΆ Flux ImageUpdateAutomation commits the tag into release.yaml (git)
βββΆ Flux helm-controller rolls out the HelmRelease
Every deployed version is a git commit β one field owner, full audit trail, trivial rollback.
This repo is a staging mirror: everything outside templates/, scripts/ and the
justfile mirrors the path it belongs to inside kubernetes-config/, so you can copy it 1:1.
| Here | β in kubernetes-config/ |
Action | |
|---|---|---|---|
| π | sources/app-template-repo.yaml |
sources/ |
add to sources/kustomization.yaml |
| ποΈ | infrastructure/kpack/ |
infrastructure/kpack/ |
copy, then vendor release.yaml (step 3) |
| π | infrastructure/flux-image-automation/ |
infrastructure/flux-image-automation/ |
copy |
| ποΈ | infrastructure/controllers/{kpack,flux-image-automation}.yaml |
infrastructure/controllers/ |
add to the controllers aggregate |
| π | apps/base/webhosting/websites/ |
apps/base/webhosting/websites/ |
reference websites from the webhosting aggregate |
| π§© | templates/website/ |
β | manifest templates used by the onboarding script |
| π§° | scripts/new-website.sh, justfile |
β | onboarding and helper commands |
All websites share the namespace webhosting. The registry/git credentials and the
kpack ServiceAccount live there exactly once. websites/demo/ is a sample site that shows
the generated output β replace or delete it.
Note
The secret files in this repo contain placeholders only. In kubernetes-config they
must hold real values and be SOPS-encrypted β Flux needs SOPS decryption configured for
the Kustomization that applies apps/.
- A Kubernetes cluster managed by Flux v2 from a GitHub repo, with SOPS decryption set up
- Traefik as ingress controller (see Adapt to your cluster)
- A GitHub account for
ghcr.iopackages - Local tools:
just,openssl,sops,flux,kubectl; forjust validateadditionallykustomizeandkubeconform
The image controllers are not part of a standard bootstrap, and the automation has to push commits back to git:
flux bootstrap github \
--owner=<you> --repository=kubernetes-config \
--branch=main --path=clusters/production --personal \
--read-write-key=true \
--components-extra=image-reflector-controller,image-automation-controllerRe-running bootstrap is idempotent. ImageUpdateAutomation checks out and pushes to main
and only touches files under apps/base/webhosting/websites/.
Use three narrowly scoped tokens instead of one broad all-repo token:
| Secret | Token | Used by | |
|---|---|---|---|
| β¬οΈ | ghcr-push |
classic PAT: read:packages + write:packages |
kpack pushes images and the builder |
| β¬οΈ | ghcr-pull |
fine-grained PAT: Packages: read | Flux registry scan + kubelet image pull |
| π₯ | github-git |
fine-grained PAT: Contents: read on the app repos | kpack clones private source repos |
Important
ghcr-push must be a classic PAT. kpack pulls its lifecycle image from
ghcr.io/buildpacks-community/kpack/lifecycle with these credentials, and a fine-grained
PAT is rejected with DENIED for that foreign namespace.
Fill in and encrypt the files in apps/base/webhosting/websites/ β each one documents a
kubectl create secret β¦ --dry-run=client -o yaml | sops --encrypt one-liner. The website
templates wire ghcr-pull in automatically (ImageRepository.secretRef and
defaultPodOptions.imagePullSecrets).
kpack ships no Helm chart, so its release YAML is committed to git:
KPACK_VERSION=v0.17.1 # check the latest: github.com/buildpacks-community/kpack/releases
curl -sSL -o infrastructure/kpack/release.yaml \
https://github.com/buildpacks-community/kpack/releases/download/$KPACK_VERSION/release-${KPACK_VERSION#v}.yamlAlso set your owner in infrastructure/kpack/cluster-builder.yaml
(tag: ghcr.io/<you>/kpack-builder). After Flux has applied it, wait for:
kubectl get clusterbuilder paketo-full # READY=TrueSet ref.tag in sources/app-template-repo.yaml to the current
app-template release.
just new-website <name> <repo-url> <host> <port> [owner] [db]
# example
just new-website myblog https://github.com/<you>/myblog myblog.example.com 3000 <you>| Argument | Meaning | |
|---|---|---|
| π·οΈ | name |
resource name and ghcr package name; only [a-z0-9-] |
| π | repo-url |
GitHub repo kpack builds from (branch main) |
| π | host |
public hostname for the Ingress |
| π | port |
port the app listens on; also exported as $PORT |
| π€ | owner |
ghcr owner; falls back to $GHCR_OWNER, lowercased automatically |
| ποΈ | db |
empty = no database, mariadb = bundle one (see Bundled database below) |
This writes apps/base/webhosting/websites/<name>/ and registers the folder in the
websites kustomization.yaml. By default the target is this repo; point it at your real
config repo with KCONFIG_ROOT:
KCONFIG_ROOT=/path/to/kubernetes-config GHCR_OWNER=<you> \
./scripts/new-website.sh myblog https://github.com/<you>/myblog myblog.example.com 3000Review the output (especially the port), commit, push β kpack builds, Flux deploys.
Pass mariadb as the sixth argument (mysql and maria are accepted aliases):
just new-website myblog https://github.com/<you>/myblog myblog.example.com 3000 <you> mariadbThis adds a second controller db to the same HelmRelease β a MariaDB 11.4 StatefulSet with
a 5 Gi PVC and a service <name>-db:3306 β through two extra files:
db-secret.yamlβ database name, user and random passwords; the single source of truth for both MariaDB and the appdb-patch.yamlβ a Kustomize strategic-merge patch, sorelease.yamlstays untouched
The app container receives DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME and
DB_PASSWORD. Laravel uses exactly these names; for other frameworks rename the keys in
db-patch.yaml. MariaDB speaks the MySQL wire protocol, so apps connect as to MySQL.
Warning
db-secret.yaml is generated with plaintext passwords. Encrypt it before committing:
sops --encrypt --in-place apps/base/webhosting/websites/<name>/db-secret.yaml
The bundled DB has no HA and no automated backups. For production-grade databases install a
dedicated operator (e.g. CloudNativePG for Postgres) under infrastructure/controllers/.
The ClusterBuilder paketo-full detects the language in this order: Node.js β Python β PHP.
- The app sits at the repo root β otherwise set
spec.source.subPathinimage.yaml. - Builds track the
mainbranch β changespec.source.git.revisioninimage.yamlif needed. - It runs a long-lived web process on a port, ideally honoring
$PORT. - Buildpacks can detect a start command; a
Procfile(web: β¦) works for every stack.
| Stack | Minimum | |
|---|---|---|
| π© | Node.js | package.json with a start script (or a Procfile) |
| π | Python | usually a Procfile, e.g. web: gunicorn app:app -b 0.0.0.0:$PORT |
| π | PHP | composer.json; set BP_PHP_WEB_DIR (e.g. public) under spec.build.env in image.yaml for a custom web root |
Some apps don't fit "detect one language" β e.g. a Laravel app with a Vite/Vue frontend
whose asset build calls php artisan and so needs PHP and Node at build time. Build those
with a multi-stage Dockerfile in GitHub Actions and push to ghcr.io/<you>/<app>, then:
- delete
image.yamland remove it from the site'skustomization.yaml; - in
imagepolicy.yaml, replacefilterTagswithpattern: '^[0-9]+$'(dropextract) and tag the CI images with the numericrun_number; - keep
release.yamlunchanged.
| What | Where | |
|---|---|---|
| π€ | <you> / you β your lowercase GitHub owner |
cluster-builder.yaml, websites/demo/, secret comments |
| π | Version pins: kpack v0.17.1, app-template 5.0.1, MariaDB 11.4, Paketo images (unpinned) β verify against current releases |
infrastructure/kpack/, sources/, templates/website/db-patch.yaml |
| π¦ | Ingress: className: traefik, entrypoint websecure, middleware traefik-security-chain@kubernetescrd (must exist in your cluster) |
templates/website/release.yaml |
| π | TLS is terminated by Traefik; uncomment the tls: block to use a per-site certificate secret instead |
templates/website/release.yaml |
| Command | What it does | |
|---|---|---|
| β | just validate |
renders every site with kustomize and checks it with kubeconform |
| π | just status |
shows Flux kustomizations, image repositories and policies, and kpack images/builds |
Issues and pull requests are welcome. For larger changes, open an issue first. Run
just validate before submitting changes to the manifests or templates.
MIT β see LICENSE. Β© 2026 Jeffry WΓΌrmli.