Files
k3s-homelab/apps/vikunja/README.md
T
2026-09-02 12:54:12 -07:00

5.7 KiB

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:

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:

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

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

# 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).