Skip to content

Discount Bandit

Self-hosted price tracker for products across multiple stores. Discount Bandit provides a Laravel and FrankenPHP web UI, scheduled crawling, price history, currency conversion, user management, and notification integrations such as email, Ntfy, Telegram, and Gotify.

The HelmForge chart defaults to the HelmForge MySQL subchart for a Kubernetes-friendly database lifecycle. SQLite remains available for development and small single-replica installs.

Key Features

  • HelmForge MySQL by default — primary database path with persistence and chart-managed credentials
  • External MySQL or MariaDB — connect to an operator-managed or platform-managed database
  • SQLite dev mode — single-replica setup with a focused PVC mount for local or personal testing
  • Gateway API and Ingress — native HTTPRoute support plus classic Kubernetes Ingress
  • External Secrets Operator v1 — manage APP_KEY, exchange-rate API key, and external database passwords
  • Service dual-stack fields — optional ipFamilyPolicy and ipFamilies
  • NetworkPolicy and PDB — optional production controls for ingress, crawler egress, database egress, and disruption
  • Supervisor hardening — removes the upstream unauthenticated Supervisor HTTP endpoint from the generated config
  • Conservative runtime defaults — explicit resources, RuntimeDefault seccomp, no service account token automount, and privilege escalation disabled

Architecture

Users
  |
  v
Gateway API HTTPRoute or Ingress
  |
  v
Service discount-bandit
  |
  v
Deployment discount-bandit
  |-- FrankenPHP web UI
  |-- Laravel scheduler
  |-- Laravel queue worker
  |-- Chromium crawler runtime
  |
  v
Service <release>-mysql
  |
  v
StatefulSet mysql
  |
  v
PVC MySQL data

For production, use the bundled MySQL subchart or an external MySQL/MariaDB service. SQLite is intentionally documented as a development path because it is single-writer and normally uses ReadWriteOnce storage.

Installation

HTTPS repository:

helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install discount-bandit helmforge/discount-bandit -n discount-bandit --create-namespace

OCI registry:

helm install discount-bandit oci://ghcr.io/helmforgedev/helm/discount-bandit -n discount-bandit --create-namespace

Default values deploy Discount Bandit with HelmForge MySQL.

Access

kubectl port-forward -n discount-bandit svc/discount-bandit-discount-bandit 8080:80

Open http://localhost:8080 and create the first admin account.

Deployment Examples

# values.yaml - default path, shown explicitly
mysql:
  enabled: true
  auth:
    database: discount_bandit
    username: discount_bandit
  standalone:
    persistence:
      enabled: true
      size: 8Gi
    resources:
      requests:
        cpu: 250m
        memory: 512Mi
      limits:
        cpu: 1000m
        memory: 1Gi

Create the Secrets referenced by the production values before installing or upgrading the release:

kubectl create namespace discount-bandit
kubectl create secret generic discount-bandit-app -n discount-bandit \
  --from-literal=app-key="base64:$(openssl rand -base64 32)"
kubectl create secret generic discount-bandit-mysql -n discount-bandit \
  --from-literal=mysql-root-password="$(openssl rand -base64 24)" \
  --from-literal=mysql-user-password="$(openssl rand -base64 24)" \
  --from-literal=mysql-replication-password="$(openssl rand -base64 24)"
discountBandit:
  appUrl: https://deals.example.com
  assetUrl: https://deals.example.com
  timezone: UTC
  themeColor: Stone
  cron: '*/10 * * * *'
  existingSecret: discount-bandit-app

mysql:
  enabled: true
  auth:
    database: discount_bandit
    username: discount_bandit
    existingSecret: discount-bandit-mysql
  standalone:
    persistence:
      enabled: true
      size: 20Gi

gatewayAPI:
  enabled: true
  parentRefs:
    - name: public-gateway
      namespace: gateway-system
  hostnames:
    - deals.example.com

networkPolicy:
  enabled: true
  ingress:
    extraFrom:
      - namespaceSelector:
          matchLabels:
            kubernetes.io/metadata.name: gateway-system
  egress:
    enabled: true
    allowDNS: true
    allowHTTPS: true
    allowSameNamespaceDatabase: true

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: 1000m
    memory: 1Gi

Create the external database password Secret before installing or upgrading the release:

kubectl create namespace discount-bandit
kubectl create secret generic discount-bandit-db -n discount-bandit \
  --from-literal=database-password="$(openssl rand -base64 24)"
mysql:
  enabled: false

database:
  mode: external
  external:
    type: mysql
    host: mysql.example.internal
    port: 3306
    name: discount_bandit
    username: discount_bandit
    existingSecret: discount-bandit-db
    existingSecretPasswordKey: database-password
mysql:
  enabled: false

database:
  mode: sqlite
  sqlite:
    enabled: true

persistence:
  database:
    enabled: true
    size: 5Gi

replicaCount: 1
mysql:
  enabled: false

database:
  mode: external
  external:
    host: mysql.example.internal
    name: discount_bandit
    username: discount_bandit

externalSecrets:
  enabled: true
  secretStoreRef:
    name: production-secrets
    kind: ClusterSecretStore
  app:
    enabled: true
    appKeyRemoteRef:
      key: apps/discount-bandit
      property: app-key
    exchangeRateApiKeyRemoteRef:
      key: apps/discount-bandit
      property: exchange-rate-api-key
  database:
    enabled: true
    passwordRemoteRef:
      key: databases/discount-bandit
      property: password

Configuration Reference

Database

Parameter Default Description
database.mode auto Database mode: auto, mysql, external, or sqlite.
mysql.enabled true Deploy HelmForge MySQL as the primary database.
mysql.auth.database discount_bandit MySQL database created by the subchart.
mysql.auth.username discount_bandit MySQL application user.
mysql.standalone.resources.requests.cpu 250m Default CPU request for bundled MySQL.
mysql.standalone.resources.requests.memory 512Mi Default memory request for bundled MySQL.
mysql.standalone.resources.limits.cpu 1000m Default CPU limit for bundled MySQL.
mysql.standalone.resources.limits.memory 1Gi Default memory limit for bundled MySQL.
database.external.host "" External MySQL or MariaDB hostname.
database.external.existingSecret "" Secret containing the external database password.
database.sqlite.enabled false Enable SQLite development mode.

Application

Parameter Default Description
discountBandit.appUrl "" Public application URL (APP_URL).
discountBandit.assetUrl "" Public asset URL (ASSET_URL). Defaults to appUrl when empty.
discountBandit.timezone UTC Application timezone.
discountBandit.themeColor Stone UI theme color.
discountBandit.cron */5 * * * * Product crawl schedule.
discountBandit.existingSecret "" Existing Secret for APP_KEY and optional exchange-rate key.
discountBandit.exchangeRateApiKey "" ExchangeRate API key. Prefer a Secret or ExternalSecret in production.
discountBandit.extraEnv [] Extra environment variables for mail, notifications, or advanced Laravel config.

Image

Parameter Default Description
image.repository docker.io/cybrarist/discount-bandit Discount Bandit container image.
image.tag "v4.0.4" Image tag.
image.pullPolicy IfNotPresent Image pull policy.

Networking

Parameter Default Description
service.type ClusterIP Kubernetes Service type.
service.port 80 Service HTTP port.
service.ipFamilyPolicy "" Optional Service IP family policy.
service.ipFamilies [] Optional Service IP families for dual-stack clusters.
ingress.enabled false Render classic Kubernetes Ingress.
gatewayAPI.enabled false Render Gateway API HTTPRoute.
gatewayAPI.parentRefs [] Platform-owned Gateway references. Required when Gateway API is enabled.
networkPolicy.enabled false Render NetworkPolicy.
networkPolicy.egress.enabled false Restrict egress when enabled.

Runtime and Operations

Parameter Default Description
serviceAccount.create true Create a dedicated ServiceAccount.
serviceAccount.automountServiceAccountToken false Avoid mounting Kubernetes API credentials into the app pod.
externalSecrets.enabled false Render External Secrets Operator resources using external-secrets.io/v1.
pdb.enabled false Render a PodDisruptionBudget.
persistence.database.enabled true SQLite data PVC. Used only in SQLite mode.
persistence.logs.enabled false Persist /logs instead of using container-local logs.
supervisor.configMap.enabled true Mount a sanitized Supervisor base config.
resources.requests.cpu 250m Default CPU request for the app container.
resources.requests.memory 512Mi Default memory request for the app container.
resources.limits.cpu 1000m Default CPU limit for the app container.
resources.limits.memory 1Gi Default memory limit for the app container.
podSecurityContext.seccompProfile.type RuntimeDefault Kubernetes seccomp profile for the pod.
securityContext.allowPrivilegeEscalation false Disable privilege escalation for the app container.

Security Scan

Framework Score
MITRE + NSA + SOC2 91.21%
MITRE 100.00%
NSA 87.50%
SOC2 92.00%

Remaining expected findings come from the upstream root runtime, writable filesystem requirements for FrankenPHP and Chromium crawlers, and NetworkPolicy/firewall decisions that depend on store and notification destinations.

Production Guidance

Use MySQL for production

Discount Bandit supports SQLite, but Kubernetes production installs should use HelmForge MySQL or an external MySQL/MariaDB database. SQLite is best kept for development and small single-user testing.

Plan crawler egress before enabling strict NetworkPolicy

Discount Bandit crawls public store pages and may call notification providers or exchange-rate APIs. Start with open egress, identify required destinations, then enable NetworkPolicy rules that match your environment.

External Secrets are optional

The chart can render external-secrets.io/v1 resources, but it does not install the External Secrets Operator or a SecretStore. Install and validate those platform components before enabling externalSecrets.enabled in production.

Runtime hardening is intentionally conservative

The upstream image currently runs as root and listens on port 80. The chart sets seccomp, resources, and privilege escalation defaults, but does not force runAsNonRoot, a read-only filesystem, or dropped capabilities until the upstream runtime supports those constraints.

Common Issues

SQLite data hidden by an incorrect mount

SQLite mode mounts only /app/database/sqlite so upstream migrations and seeders under /app/database remain visible. Do not replace this with a broad /app/database mount.

Prices not updating

Price updates are driven by the Laravel scheduler and queue worker inside the container. Check pod logs and confirm outbound access to target stores, notification providers, and exchange-rate APIs.

More Information