Skip to content

Liwan

Ultra-lightweight, privacy-first web analytics with embedded DuckDB storage. Liwan runs as a single binary with minimal resource requirements and no external database dependency. All analytics data — including GeoIP location enrichment — lives in a local DuckDB file on a persistent volume.

Key Features

  • Single binary — no external database, no sidecar, minimal footprint
  • Privacy-first — no cookies, no cross-site tracking, GDPR-compliant by design
  • DuckDB storage — columnar analytics engine embedded in the binary
  • GeoIP enrichment — automatic IP-to-location lookup stored alongside DuckDB data
  • Cookie-free tracking — JavaScript snippet embeddable on any site
  • Ingress support — TLS via cert-manager with configurable ingress class
  • Gateway API support — optional HTTPRoute for native Kubernetes routing
  • Dual-stack ready Service — optional ipFamilyPolicy and ipFamilies
  • Non-root by defaultrunAsNonRoot: true and fsGroup: 1000 preconfigured

Installation

HTTPS repository:

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 Integration

After deploying Liwan and creating a site in the UI, embed the tracking snippet on your website. Replace analytics.example.com with your actual Liwan instance URL and my-site with your site slug:

<script defer src="https://analytics.example.com/script.js" data-site-name="my-site"></script>

The script is cookie-free and GDPR-compliant. No consent banner is required under most EU interpretations when using Liwan’s privacy-preserving tracking model.

Deployment Examples

# values.yaml — Production Liwan with TLS and resource limits
liwan:
  baseUrl: 'https://analytics.example.com'

persistence:
  enabled: true
  size: 5Gi

resources:
  requests:
    memory: 64Mi
    cpu: 25m
  limits:
    memory: 256Mi
    cpu: 250m

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.com
# values.yaml — Liwan with GeoIP enrichment and explicit resource limits
# GeoIP data is automatically downloaded to /data on first startup.
# A larger PVC is recommended when GeoIP enrichment is active.
liwan:
  baseUrl: 'https://analytics.example.com'

persistence:
  enabled: true
  size: 5Gi # GeoIP database adds ~100MB to the DuckDB data directory
  storageClass: fast-ssd

resources:
  requests:
    memory: 128Mi
    cpu: 50m
  limits:
    memory: 512Mi
    cpu: 500m

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.com
# values.yaml — Minimal Liwan, internal access only (no Ingress)
liwan:
  baseUrl: 'http://liwan.analytics.svc.cluster.local'

persistence:
  enabled: true
  size: 2Gi

Configuration Reference

Core

ParameterTypeDefaultDescription
nameOverridestring""Override the chart name.
fullnameOverridestring""Override the full release name.
commonLabelsobject{}Extra labels added to all resources.

Image

ParameterTypeDefaultDescription
image.repositorystringghcr.io/explodingcamera/liwanLiwan container image.
image.tagstring"1.5.0"Image tag.
image.pullPolicystringIfNotPresentImage pull policy.
imagePullSecretsarray[]Pull secrets for private registries.

Liwan Configuration

ParameterTypeDefaultDescription
liwan.portinteger9042Application HTTP port inside the container.
liwan.baseUrlstring""Public base URL of the Liwan instance (e.g. https://analytics.example.com).
liwan.extraEnvarray[]Extra environment variables for advanced configuration.
baseUrl is required for correct tracking script URLs

Set liwan.baseUrl to your public URL. Without it, the JavaScript tracking snippet URL generated by Liwan will be incorrect, causing the tracker to fail on embedded sites. This is the most common reason for a Liwan deployment that appears to work but collects no data.

Persistence

Liwan stores both the DuckDB analytics database and the GeoIP enrichment database in the /data directory. GeoIP data is downloaded automatically on first startup and adds approximately 100 MB to the volume.

ParameterTypeDefaultDescription
persistence.enabledbooleantrueEnable a PVC for /data (DuckDB database + GeoIP data).
persistence.sizestring2GiPVC size.
persistence.storageClassstring""StorageClass for the PVC.
persistence.accessModesarray["ReadWriteOnce"]PVC access modes.
persistence.existingClaimstring""Use an existing PVC instead of creating one.

Service

ParameterTypeDefaultDescription
service.typestringClusterIPKubernetes service type.
service.portinteger80Service port exposed to the cluster.
service.annotationsobject{}Annotations for the Service.
service.ipFamilyPolicystring/nullnullService IP family policy.
service.ipFamiliesarray[]Ordered Service IP families.

Dual-Stack Service

service:
  ipFamilyPolicy: PreferDualStack
  ipFamilies:
    - IPv4
    - IPv6

Gateway API

Use gatewayAPI.enabled to render a native Kubernetes HTTPRoute for Liwan. This requires Gateway API CRDs and a compatible Gateway controller in the cluster.

gatewayAPI:
  enabled: true
  parentRefs:
    - name: shared-gateway
      namespace: gateway-system
      sectionName: https
  hostnames:
    - analytics.example.com
ParameterTypeDefaultDescription
gatewayAPI.enabledboolfalseRender an HTTPRoute.
gatewayAPI.parentRefsarray[]Parent Gateway references.
gatewayAPI.hostnamesarray[]HTTPRoute hostnames.
gatewayAPI.pathsarray[{ type: PathPrefix, value: "/" }]HTTPRoute path matches.
gatewayAPI.annotationsobject{}HTTPRoute annotations.

Ingress

ParameterTypeDefaultDescription
ingress.enabledbooleanfalseEnable an Ingress resource.
ingress.ingressClassNamestringtraefikIngress class name.
ingress.annotationsobject{}Annotations for the Ingress (e.g. cert-manager).
ingress.hostsarray[]Ingress host and path rules.
ingress.tlsarray[]TLS configuration (secret name and hosts).

Probes

ParameterTypeDefaultDescription
probes.startup.enabledbooleantrueEnable startup probe.
probes.startup.initialDelaySecondsinteger5Startup probe initial delay.
probes.startup.periodSecondsinteger5Startup probe period.
probes.startup.timeoutSecondsinteger3Startup probe timeout.
probes.startup.failureThresholdinteger30Startup probe failure threshold.
probes.liveness.enabledbooleantrueEnable liveness probe.
probes.liveness.initialDelaySecondsinteger0Liveness probe initial delay.
probes.liveness.periodSecondsinteger15Liveness probe period.
probes.liveness.timeoutSecondsinteger5Liveness probe timeout.
probes.liveness.failureThresholdinteger3Liveness probe failure threshold.
probes.readiness.enabledbooleantrueEnable readiness probe.
probes.readiness.initialDelaySecondsinteger0Readiness probe initial delay.
probes.readiness.periodSecondsinteger10Readiness probe period.
probes.readiness.timeoutSecondsinteger5Readiness probe timeout.
probes.readiness.failureThresholdinteger3Readiness probe failure threshold.

Resources and Security

Liwan ships with a non-root security context preconfigured. The fsGroup: 1000 ensures the DuckDB and GeoIP files in /data are writable by the container user without requiring privileged access.

ParameterTypeDefaultDescription
resourcesobject{}CPU and memory requests and limits.
podSecurityContext.fsGroupinteger1000Filesystem group for the pod.
securityContext.runAsUserinteger1000UID for the container process.
securityContext.runAsGroupinteger1000GID for the container process.
securityContext.runAsNonRootbooleantrueEnforce non-root execution.

Service Account

ParameterTypeDefaultDescription
serviceAccount.createbooleanfalseCreate a dedicated ServiceAccount.
serviceAccount.namestring""Override the ServiceAccount name.
serviceAccount.annotationsobject{}Annotations for the ServiceAccount.

Scheduling

ParameterTypeDefaultDescription
nodeSelectorobject{}Node selector for scheduling.
tolerationsarray[]Tolerations for scheduling.
affinityobject{}Affinity rules.
topologySpreadConstraintsarray[]Topology spread constraints.
priorityClassNamestring""PriorityClass for the pod.
terminationGracePeriodSecondsinteger30Termination grace period.
podLabelsobject{}Extra labels for the pod.
podAnnotationsobject{}Extra annotations for the pod.

Extra

ParameterTypeDefaultDescription
extraVolumesarray[]Extra volumes to attach to the pod.
extraVolumeMountsarray[]Extra volume mounts for the container.
extraManifestsarray[]Extra Kubernetes manifests deployed alongside the chart.

Common Issues

All analytics data lost when persistence.enabled: false

If persistence.enabled is false, all DuckDB data — including historical analytics and GeoIP files — is lost when the pod restarts. Always enable persistence in production.

Right-size your PVC from the start

DuckDB compresses analytics data efficiently. For most small-to-medium sites, 2Gi is sufficient for years of analytics. Add ~100 MB for the GeoIP database. PVC resize requires a StorageClass with allowVolumeExpansion: true. Start at 5Gi for peace of mind.

More Information