Liwan
Liwan is a lightweight, privacy-focused web analytics application with embedded DuckDB storage. The HelmForge chart
deploys it as one Kubernetes workload with durable /data storage, explicit public URL wiring, and production-oriented
security defaults.
Key Features
- Official image - pinned
ghcr.io/explodingcamera/liwan:1.5.0 - Embedded DuckDB - no external database dependency
- PVC-backed data - analytics and runtime assets stored under
/data - Recreate rollout - avoids overlapping DuckDB writers during upgrades
- Cookie-free tracking - small JavaScript snippet for tracked sites
- Ingress support - host, path, class, annotations, and TLS
- Gateway API support - optional HTTPRoute
- Dual-stack ready Service - optional
ipFamilyPolicyandipFamilies - Hardened defaults - non-root user, dropped capabilities,
RuntimeDefaultseccomp, and no ServiceAccount token
Installation
helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install liwan helmforge/liwan
OCI registry:
helm install liwan oci://ghcr.io/helmforgedev/helm/liwan
Tracking Script
After deploying Liwan and creating a site in the UI, embed the generated tracking snippet on your website. A typical snippet looks like:
<script defer src="https://analytics.example.com/script.js" data-site-name="my-site"></script>
Set liwan.baseUrl to the same public URL used by browsers and tracking scripts. Consent requirements depend on your
jurisdiction, site policy, and any additional data you collect around Liwan.
Deployment Examples
liwan:
baseUrl: 'https://analytics.example.com'
persistence:
enabled: true
size: 5Gi
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: analytics.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: liwan-tls
hosts:
- analytics.example.comliwan:
baseUrl: 'https://analytics.example.com'
persistence:
enabled: true
size: 5Gi
gatewayAPI:
enabled: true
parentRefs:
- name: shared-gateway
namespace: gateway-system
sectionName: https
hostnames:
- analytics.example.comliwan:
baseUrl: 'http://liwan.analytics.svc.cluster.local'
persistence:
enabled: true
size: 2GiArchitecture
The chart renders a Deployment, Service, and PVC by default. Liwan listens on container port 9042; the Kubernetes
Service exposes port 80.
The Deployment uses strategy.type: Recreate. DuckDB is embedded and single-writer oriented, so the chart avoids a
rolling update that could briefly run two pods against the same data volume.
Without liwan.baseUrl, generated script URLs can point at an internal or incomplete endpoint. Keep liwan.baseUrl,
Ingress hosts, Gateway API hostnames, and TLS hosts aligned.
Configuration Reference
Core
| Parameter | Type | Default | Description |
|---|---|---|---|
nameOverride |
string | "" |
Override the chart name. |
fullnameOverride |
string | "" |
Override the full release name. |
commonLabels |
object | {} |
Extra labels added to all resources. |
Image
| Parameter | Type | Default | Description |
|---|---|---|---|
image.repository |
string | ghcr.io/explodingcamera/liwan |
Liwan container image. |
image.tag |
string | "1.5.0" |
Image tag. |
image.pullPolicy |
string | IfNotPresent |
Image pull policy. |
imagePullSecrets |
array | [] |
Pull secrets for private registries. |
Liwan
| Parameter | Type | Default | Description |
|---|---|---|---|
liwan.port |
integer | 9042 |
Application HTTP port inside the container. |
liwan.baseUrl |
string | "" |
Public base URL of the Liwan instance. |
liwan.extraEnv |
array | [] |
Extra environment variables for advanced upstream settings. |
Persistence
Liwan stores analytics data and runtime assets in /data.
| Parameter | Type | Default | Description |
|---|---|---|---|
persistence.enabled |
boolean | true |
Enable a PVC for /data. |
persistence.size |
string | 2Gi |
PVC size. |
persistence.storageClass |
string | "" |
StorageClass for the PVC. |
persistence.accessModes |
array | ["ReadWriteOnce"] |
PVC access modes. |
persistence.existingClaim |
string | "" |
Use an existing PVC instead of creating one. |
Service
| Parameter | Type | Default | Description |
|---|---|---|---|
service.type |
string | ClusterIP |
Kubernetes Service type. |
service.port |
integer | 80 |
Service port exposed to the cluster. |
service.annotations |
object | {} |
Annotations for the Service. |
service.ipFamilyPolicy |
string/null | null |
Service IP family policy. |
service.ipFamilies |
array | [] |
Ordered Service IP families. |
Dual-stack example:
service:
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
Ingress
| Parameter | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
boolean | false |
Render an Ingress. |
ingress.ingressClassName |
string | traefik |
Ingress class name. |
ingress.annotations |
object | {} |
Ingress annotations. |
ingress.hosts |
array | [] |
Host and path rules. |
ingress.tls |
array | [] |
TLS configuration. |
Gateway API
Use gatewayAPI.enabled to render a native Kubernetes HTTPRoute. This requires Gateway API CRDs and a compatible
Gateway controller.
| Parameter | Type | Default | Description |
|---|---|---|---|
gatewayAPI.enabled |
boolean | false |
Render an HTTPRoute. |
gatewayAPI.parentRefs |
array | [] |
Parent Gateway references. |
gatewayAPI.hostnames |
array | [] |
HTTPRoute hostnames. |
gatewayAPI.paths |
array | [{ type: PathPrefix, value: "/" }] |
HTTPRoute path matches. |
gatewayAPI.annotations |
object | {} |
HTTPRoute annotations. |
Security
| Parameter | Type | Default | Description |
|---|---|---|---|
serviceAccount.create |
boolean | false |
Create a dedicated ServiceAccount. |
serviceAccount.name |
string | "" |
Override the ServiceAccount name. |
serviceAccount.annotations |
object | {} |
ServiceAccount annotations. |
serviceAccount.automountServiceAccountToken |
boolean | false |
Mount a Kubernetes API token into the pod. |
podSecurityContext.fsGroup |
integer | 1000 |
Filesystem group for mounted data. |
podSecurityContext.fsGroupChangePolicy |
string | OnRootMismatch |
Avoid recursive ownership changes when possible. |
podSecurityContext.seccompProfile.type |
string | RuntimeDefault |
Use the runtime default seccomp profile. |
securityContext.runAsUser |
integer | 1000 |
Container user ID. |
securityContext.runAsGroup |
integer | 1000 |
Container group ID. |
securityContext.runAsNonRoot |
boolean | true |
Enforce non-root execution. |
securityContext.allowPrivilegeEscalation |
boolean | false |
Prevent privilege escalation. |
securityContext.capabilities.drop |
array | ["ALL"] |
Drop Linux capabilities. |
Scheduling and Extensibility
| Parameter | Type | Default | Description |
|---|---|---|---|
resources |
object | {} |
CPU and memory requests and limits. |
nodeSelector |
object | {} |
Node selector for scheduling. |
tolerations |
array | [] |
Tolerations for scheduling. |
affinity |
object | {} |
Affinity rules. |
topologySpreadConstraints |
array | [] |
Topology spread constraints. |
priorityClassName |
string | "" |
PriorityClass for the pod. |
terminationGracePeriodSeconds |
integer | 30 |
Termination grace period. |
podLabels |
object | {} |
Extra labels for the pod. |
podAnnotations |
object | {} |
Extra annotations for the pod. |
extraVolumes |
array | [] |
Extra volumes to attach to the pod. |
extraVolumeMounts |
array | [] |
Extra volume mounts for the container. |
extraManifests |
array | [] |
Additional Kubernetes manifests rendered with the release. |
Operational Patterns
Liwan is intentionally single-instance. The chart does not expose replicaCount because embedded DuckDB is not a shared
multi-writer database. Run separate releases for separate environments or sites when isolation is required.
Back up the PVC mounted at /data. For the most consistent snapshot, pause writes or scale the Deployment to zero before
taking a storage-level snapshot.
For upgrades, the Recreate strategy causes a short outage while the old pod exits and the new pod starts. That is
intentional and avoids overlapping writers on the same DuckDB files.
Common Issues
If persistence.enabled is false, Liwan data is stored on emptyDir and is lost when the pod restarts. Keep
persistence enabled in production.
DuckDB compresses analytics data efficiently, but retention and traffic vary. Start at 5Gi for public sites and use
a StorageClass that supports expansion if long retention is required.
Security Scan
| Framework | Score |
|---|---|
| Overall | 84.85% |
| MITRE | 100.00% |
| NSA | 80.00% |
| SOC2 | 80.00% |
The hardening defaults cover non-root execution, dropped Linux capabilities, RuntimeDefault seccomp, and disabled
ServiceAccount token automount. Remaining environment-specific findings are resource limits, network policy, and
immutable root filesystem.