diff --git a/apps/vikunja/README.md b/apps/vikunja/README.md new file mode 100644 index 0000000..2445829 --- /dev/null +++ b/apps/vikunja/README.md @@ -0,0 +1,174 @@ +# Vikunja + +Self-hosted to-do / task management (Vikunja `2.6.0`), served on the internal network. + +## URL + +- Web: `https://vikunja.aleshym.co` + +## Internal DNS + +Pi-hole local DNS record (create it manually if not present): + +``` +vikunja.aleshym.co A 10.10.0.100 +``` + +`10.10.0.100` is the k3s node and the `traefik` LoadBalancer's external IP — the same +destination Vaultwarden uses for `vault.aleshym.co`. + +## Architecture + +This app is **private** (LAN/VPN only, no public WAN exposure), exactly like +Vaultwarden. It is deliberately absent from the k3s `catchall-to-docker-apps` public +host list and from the Docker Traefik `wan` entrypoint. Pi-hole resolves the hostname to +the node IP, and Traefik terminates TLS for it. + +``` +Pi-hole DNS + | + v +10.10.0.100 (k3s node / Traefik LoadBalancer external IP) + | +Traefik (ingressClassName traefik, websecure entrypoint, letsencrypt-prod) + | +Vikunja Service (ClusterIP, port 80 -> pod http) + | +Vikunja Pod :3456 + | + +-- SQLite PVC (/db/vikunja.db) + | + +-- Files PVC (/app/vikunja/files) +``` + +No NodePort / hostNetwork / separate LoadBalancer is used for Vikunja; Traefik remains the +single ingress point. + +## Secret generation + +The service secret (`VIKUNJA_SERVICE_SECRET`) must come from a Kubernetes Secret named +`vikunja-secret` in the `vikunja` namespace, which is generated from a **SealedSecret**. +The encrypted sealed secret is *not* committed in plaintext form. + +Generate the real `apps/vikunja/vikunja-sealedsecret.yaml` from the repo root: + +```bash +apps/vikunja/generate-secret.sh +``` + +This script: + +1. generates a strong random secret (`openssl rand -hex 64`), +2. builds the Secret with `kubectl create ... --dry-run=client` (nothing touches the cluster), +3. pipes it through `kubeseal` using this cluster's controller + (`--controller-name sealed-secrets-controller --controller-namespace kube-system`), +4. writes the encrypted manifest to `apps/vikunja/vikunja-sealedsecret.yaml`. + +It never prints the secret. Commit the generated file before/alongside the Argo CD sync so +Vikunja gets a stable JWT-signing secret across restarts. Equivalent manual one-liner: + +```bash +SECRET="$(openssl rand -hex 64)" +kubectl create secret generic vikunja-secret \ + --namespace vikunja \ + --from-literal=VIKUNJA_SERVICE_SECRET="$SECRET" \ + --dry-run=client -o yaml \ +| kubeseal \ + --controller-name sealed-secrets-controller \ + --controller-namespace kube-system \ + --format yaml \ + --namespace vikunja \ + --name vikunja-secret \ +> apps/vikunja/vikunja-sealedsecret.yaml +unset SECRET +``` + +The controller's private key must be restored on the cluster before unsealing works (see +`apps/sealed-secrets/README.md`). + +## Deployment + +`argocd/vikunja.yaml` registers an Argo CD `Application` pointing at: + +``` +repoURL: https://git.aleshym.co/funman300/k3s-homelab.git +targetRevision: main +path: apps/vikunja +destination: https://kubernetes.default.svc / namespace vikunja +automated: prune: true, selfHeal: true +syncOptions: CreateNamespace=true +``` + +Flow: + +1. commit `apps/vikunja/*` and `argocd/vikunja.yaml` to `main`, +2. push, +3. apply the Application once: `kubectl apply -f argocd/vikunja.yaml`, +4. Argo CD syncs the app, creates the `vikunja` namespace, deploys everything, and + continuously self-heals it back to the Git state (prunes removed resources too). + +## Verification + +```bash +kubectl -n vikunja get all +kubectl -n vikunja get pvc +kubectl -n vikunja get ingress +kubectl -n vikunja describe deployment vikunja +kubectl -n vikunja logs deployment/vikunja +curl -k https://vikunja.aleshym.co/health +``` + +## Registration + +Registration is **enabled** initially so the first account can be created at +`https://vikunja.aleshym.co`. It is controlled by: + +- Config key: `service.enableregistration` +- Environment variable: `VIKUNJA_SERVICE_ENABLEREGISTRATION` + +It is set to `"true"` in `apps/vikunja/vikunja-deployment.yaml`. **After creating your +account**, disable new registrations: + +1. Edit `apps/vikunja/vikunja-deployment.yaml` +2. Set `VIKUNJA_SERVICE_ENABLEREGISTRATION` to `"false"` +3. Commit and push +4. Argo CD (selfHeal) syncs the new Deployment and the Recreate strategy rolls the pod + +Once disabled, additional accounts can still be created via the CLI +(`vikunja user create`), e.g. in a temporary pod mounting the same PVC. + +## Troubleshooting + +```bash +# pod status / events / logs +kubectl -n vikunja get pods -o wide +kubectl -n vikunja get events --sort-by=.lastTimestamp +kubectl -n vikunja logs deployment/vikunja + +# PVC status (bound? local-path volume created?) +kubectl -n vikunja get pvc +kubectl -n vikunja describe pvc vikunja-db vikunja-files + +# ingress + certificate (TLS via letsencrypt-prod) +kubectl -n vikunja describe ingress +kubectl -n vikunja get certificate +kubectl -n vikunja describe certificate + +# sealed secret + generated secret +kubectl -n vikunja get sealedsecret +kubectl -n vikunja get sealedsecret vikunja-secret -o yaml +kubectl -n vikunja get secret vikunja-secret +``` + +### SQLite / volume permissions + +- Runtime user is UID/GID `1000` (the official image sets `USER 1000`); the pod sets + `runAsUser/runAsGroup/fsGroup: 1000` so the `local-path`-provisioned volumes are handed + to group 1000 and remain writable. If you ever see "permission denied" writing + `/db/vikunja.db`, verify the PVCs are `Bound` and the fsGroup is applied + (`kubectl -n vikunja describe pod`). +- SQLite + `Recreate` strategy: only one pod runs at a time, so the old pod never writes + the database while the new one migrates/launches. +- The `local-path` storage class has `reclaimPolicy: Delete` — deleting a PVC destroys the + database/files. Back up `/db` and `/app/vikunja/files` (see `apps/sealed-secrets` backups + for the general DR posture; adapt for vikunja volumes). diff --git a/apps/vikunja/generate-secret.sh b/apps/vikunja/generate-secret.sh new file mode 100755 index 0000000..8dd6c8a --- /dev/null +++ b/apps/vikunja/generate-secret.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Generates apps/vikunja/vikunja-sealedsecret.yaml from a freshly-created random +# VIKUNJA_SERVICE_SECRET, sealed with the cluster's sealed-secrets controller. +# +# Run from the repository root: +# apps/vikunja/generate-secret.sh +# +# Requirements: kubectl (with access to the cluster), kubeseal, openssl. +# The real secret value is never printed to stdout. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT="${REPO_ROOT}/apps/vikunja/vikunja-sealedsecret.yaml" +NAMESPACE="vikunja" +SECRET_NAME="vikunja-secret" +# Controller is deployed as: kubectl -n kube-system get svc sealed-secrets-controller +CONTROLLER_NAME="${SEALED_SECRETS_CONTROLLER_NAME:-sealed-secrets-controller}" +CONTROLLER_NAMESPACE="${SEALED_SECRETS_CONTROLLER_NAMESPACE:-kube-system}" + +# Build the Secret object client-side only (nothing is applied to the cluster), +# then seal it. kubeseal carries the secret name/namespace into the output's +# spec.template so the controller recreates the Secret with the right metadata. +SECRET="$(openssl rand -hex 64)" + +kubectl create secret generic "${SECRET_NAME}" \ + --namespace "${NAMESPACE}" \ + --from-literal=VIKUNJA_SERVICE_SECRET="${SECRET}" \ + --dry-run=client -o yaml \ +| kubeseal \ + --controller-name "${CONTROLLER_NAME}" \ + --controller-namespace "${CONTROLLER_NAMESPACE}" \ + --format yaml \ + --namespace "${NAMESPACE}" \ + --name "${SECRET_NAME}" \ +> "${OUT}" + +unset SECRET + +echo "Wrote ${OUT}" +echo "Commit the generated file before Argo CD syncs the vikunja app." diff --git a/apps/vikunja/kustomization.yaml b/apps/vikunja/kustomization.yaml new file mode 100644 index 0000000..44e160a --- /dev/null +++ b/apps/vikunja/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - vikunja-sealedsecret.yaml + - vikunja-db-pvc.yaml + - vikunja-files-pvc.yaml + - vikunja-deployment.yaml + - vikunja-service.yaml + - vikunja-ingress.yaml diff --git a/apps/vikunja/namespace.yaml b/apps/vikunja/namespace.yaml new file mode 100644 index 0000000..0f6366f --- /dev/null +++ b/apps/vikunja/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vikunja diff --git a/apps/vikunja/vikunja-db-pvc.yaml b/apps/vikunja/vikunja-db-pvc.yaml new file mode 100644 index 0000000..d6291c5 --- /dev/null +++ b/apps/vikunja/vikunja-db-pvc.yaml @@ -0,0 +1,14 @@ +# local-path has reclaimPolicy: Delete -- deleting this PVC destroys the database. +# Holds the SQLite database file at /db/vikunja.db. +# Small for now (personal single-user install); grow it here if the db ever grows. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vikunja-db + namespace: vikunja +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/apps/vikunja/vikunja-deployment.yaml b/apps/vikunja/vikunja-deployment.yaml new file mode 100644 index 0000000..42148e0 --- /dev/null +++ b/apps/vikunja/vikunja-deployment.yaml @@ -0,0 +1,92 @@ +# Single-replica because Vikunja uses SQLite on a ReadWriteOnce volume: never run +# two pods against the same .db file. Recreate (not RollingUpdate) guarantees the +# old pod is gone before the new one starts, so migrations and writes can't collide. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vikunja + namespace: vikunja +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: vikunja + template: + metadata: + labels: + app: vikunja + spec: + securityContext: + # The official image sets USER 1000 and the install docs chown /db and the + # files dir to 1000. local-path volumes are root-owned, so fsGroup 1000 makes + # the kubelet hand the PVCs to group 1000, which the process (uid 1000 / gid + # 1000) matches. Without this Vikunja cannot write /db/vikunja.db or files. + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: vikunja + image: vikunja/vikunja:2.6.0 + ports: + - name: http + containerPort: 3456 + env: + - name: VIKUNJA_SERVICE_PUBLICURL + value: https://vikunja.aleshym.co/ + - name: VIKUNJA_DATABASE_TYPE + value: sqlite + - name: VIKUNJA_DATABASE_PATH + value: /db/vikunja.db + - name: VIKUNJA_FILES_BASEPATH + value: /app/vikunja/files + # Registration is on for initial setup so the first account can be created. + # Flip to "false" (see README) after that account exists. + - name: VIKUNJA_SERVICE_ENABLEREGISTRATION + value: "true" + - name: VIKUNJA_SERVICE_SECRET + valueFrom: + secretKeyRef: + name: vikunja-secret + key: VIKUNJA_SERVICE_SECRET + volumeMounts: + - name: db + mountPath: /db + - name: files + mountPath: /app/vikunja/files + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + # First boot runs SQLite database migrations, which can take longer than the + # liveness threshold. A startupProbe overrides liveness until it succeeds so + # the pod isn't killed mid-migration. + startupProbe: + httpGet: + path: /health + port: http + failureThreshold: 30 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: db + persistentVolumeClaim: + claimName: vikunja-db + - name: files + persistentVolumeClaim: + claimName: vikunja-files diff --git a/apps/vikunja/vikunja-files-pvc.yaml b/apps/vikunja/vikunja-files-pvc.yaml new file mode 100644 index 0000000..2284538 --- /dev/null +++ b/apps/vikunja/vikunja-files-pvc.yaml @@ -0,0 +1,14 @@ +# local-path has reclaimPolicy: Delete -- deleting this PVC destroys uploaded +# task attachments and project files. Base path is /app/vikunja/files. +# 5Gi gives comfortable headroom for attachments; bump it here when needed. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vikunja-files + namespace: vikunja +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/apps/vikunja/vikunja-ingress.yaml b/apps/vikunja/vikunja-ingress.yaml new file mode 100644 index 0000000..1eec130 --- /dev/null +++ b/apps/vikunja/vikunja-ingress.yaml @@ -0,0 +1,31 @@ +# vikunja.aleshym.co is PRIVATE, exactly like vault.aleshym.co. It is deliberately +# absent from the k3s `catchall-to-docker-apps` public host list and from the Docker +# Traefik `wan` entrypoint, so the internet has no route to it. Pi-hole resolves the +# name to 10.10.0.100 (the k3s node / Traefik external IP) for LAN/VPN clients. +# Traffic: client -> vikunja.aleshym.co -> 10.10.0.100 -> Traefik -> vikunja service +# -> vikunja pod:3456 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: vikunja + namespace: vikunja + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: vikunja.aleshym.co + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: vikunja + port: + name: http + tls: + - hosts: + - vikunja.aleshym.co + secretName: vikunja-tls diff --git a/apps/vikunja/vikunja-sealedsecret.yaml b/apps/vikunja/vikunja-sealedsecret.yaml new file mode 100644 index 0000000..d2419c6 --- /dev/null +++ b/apps/vikunja/vikunja-sealedsecret.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: vikunja-secret + namespace: vikunja +spec: + encryptedData: + VIKUNJA_SERVICE_SECRET: AgBShyuXLEeAyiM4NLx8ur0QrPgKgCEMnHbp1Va9YDTE2paHuZkL0tsPeAZMEOiovS5fpvI7gC6LmRAYM+1cYvoXefChelQPw0xZDvdP1VBHL0JqrYGn/SXieIM+wxXSMdWEXWcfEBMG9hdXjLwoswjs255TKUf0/HCCnYdd+FERkzBZTH0XjCWd6bhwwBlZ5ISQTpYd+E8+Y/np6O994H5k8OH5uzILNCDiHsoWZWLAg41vlioutnJMPt5Vfo3N35+sf0zzC93KNpsZU7weTZrtwP3U+2ZzB/Uuw7opxFPXrykGxZBPZbt4BKoGyEpuf//dn5/HYvFLfJXJVA2sFjt/Ml1ZWx4f6n/VKBvXt/8MtndRompPfCyhT/EUemJvrIGGaizk/hy9hwvcpQNe8a/GZlhS5zhtW82ebBqEdU0bS7hUZEiJ9Swi73E5+7QidRw5Rj14XkD0nQ+E5G7BdY8xvVS3ShDvC2o4NbEFKkCMIoKZBtC+n208An7OzuFBJF3r8XwHChALk/MTNpHlSWWQIWO8YIkWAB28ykypbRjkhDbogt+w7I6q4oO5LvRq2CTmJuaZfdKj1YNbPPpf4fSwO3L79GvJLck/8rP9BQoJyjj+zKM2bomNJ8YKQF0KMYJo3RtBl6eZVu4vHoTRwmLUGdxszh7up+Ni4vHlyW6f0HBlxcUnmyb6IW+EsiG1QrRkOJhITP2pgEx51DSFQinzLr4clO5CZ/+oXDwGTzLypxWI/oW7/PUxCpN/T9tUZjd3K8G586lzM6daCQF2rmA6NWrHzt6Bnr8EqNxJbOgXWy4QfJ2wCjAvVLmxrVCv3CRf31Ag5Lrn0Ji17SDw5KegjIh/7WsgJ+RmDlkfAF6OJQ== + template: + metadata: + name: vikunja-secret + namespace: vikunja diff --git a/apps/vikunja/vikunja-secret.yaml.example b/apps/vikunja/vikunja-secret.yaml.example new file mode 100644 index 0000000..4343fc0 --- /dev/null +++ b/apps/vikunja/vikunja-secret.yaml.example @@ -0,0 +1,16 @@ +# Template only -- the real value lives in vikunja-sealedsecret.yaml, which is +# produced by apps/vikunja/generate-secret.sh (sealed with the cluster's +# sealed-secrets key). Do not commit this as a live secret. +# +# VIKUNJA_SERVICE_SECRET signs Vikunja JWTs and other cryptographic data. Without a +# stable value Vikunja would generate a new random secret on every restart, +# invalidating all issued session tokens. Use at least 64 hex chars (the generator +# uses `openssl rand -hex 64`). +apiVersion: v1 +kind: Secret +metadata: + name: vikunja-secret + namespace: vikunja +type: Opaque +stringData: + VIKUNJA_SERVICE_SECRET: "REPLACE_WITH_LONG_RANDOM_HEX" diff --git a/apps/vikunja/vikunja-service.yaml b/apps/vikunja/vikunja-service.yaml new file mode 100644 index 0000000..2e8b8d4 --- /dev/null +++ b/apps/vikunja/vikunja-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: vikunja + namespace: vikunja +spec: + selector: + app: vikunja + ports: + - name: http + port: 80 + targetPort: http diff --git a/argocd/vikunja.yaml b/argocd/vikunja.yaml new file mode 100644 index 0000000..b262267 --- /dev/null +++ b/argocd/vikunja.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: vikunja + namespace: argocd +spec: + project: default + source: + repoURL: https://git.aleshym.co/funman300/k3s-homelab.git + targetRevision: main + path: apps/vikunja + destination: + server: https://kubernetes.default.svc + namespace: vikunja + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true