It’s a Plan
Self-hosting

Deploy on Kubernetes

The Helm chart deploys the full stack on any Kubernetes 1.24+ cluster. It runs the published images, with built-in PostgreSQL and RustFS that can each be swapped for an external service.

AGPL-3.0 · Kubernetes 1.24+ · Helm 3.10+

Requirements

  • Kubernetes 1.24+Any cluster, managed or your own.
  • Helm 3.10+The chart is in charts/itsaplan/.

The chart runs the images published to GHCR, with built-in PostgreSQL and RustFS. Each of those can be swapped for an external service.

Install

Create a values file for the deployment:

api:
  env:
    API_URL: "https://api.example.com"
    APP_URL: "https://app.example.com"
    S3_BUCKET: "planner-attachments"

secrets:
  postgresPassword: ""    # openssl rand -base64 32
  betterAuthSecret: ""    # openssl rand -base64 32
  appEncryptionKey: ""    # openssl rand -base64 32
  s3AccessKeyId: "minioadmin"
  s3SecretAccessKey: "minioadmin"

Install the chart:

helm install itsaplan charts/itsaplan -f values.yaml

Each api pod applies the database migrations in its migrate init container before the server starts. With several replicas, one applies them and the others wait on a Postgres advisory lock. Worker and bot pods wait in their own init container until the migrations are applied. The first account registered becomes the instance admin.

What gets deployed

  • apiDeployment and Service. Always.
  • webDeployment and Service. Always.
  • workerDeployment. Always.
  • botDeployment, on bot.enabled (default true).
  • PostgreSQLStatefulSet, Service and PVC, on postgresql.enabled (default true).
  • RustFSDeployment, Service and PVC, plus a bucket init Job, on minio.enabled (default true).

Ingress, TLS certificates and a ServiceAccount are available but disabled by default.

Ingress

The chart’s ingress has two modes for the api.

Separate host. The web app and the api each get their own hostname. Works with any ingress controller.

ingress:
  enabled: true
  className: nginx
  host: app.example.com
  tls:
    enabled: true
    secretName: app-tls
  api:
    mode: separate-host
    host: api.example.com
    tls:
      enabled: true
      secretName: api-tls

Traefik entrypoint. The api shares the web hostname on a dedicated entrypoint, which is a separate port. Needs Traefik and the IngressRoute CRD.

ingress:
  enabled: true
  className: traefik
  host: app.example.com
  tls:
    enabled: true
    secretName: app-tls
  api:
    mode: traefik-entrypoint
    entryPoint: apisecure
    publicPort: 8443
    tls:
      enabled: true
      secretName: app-tls

TLS with cert-manager

certificate:
  enabled: true
  issuerName: letsencrypt-prod
  issuerKind: ClusterIssuer
  dnsNames:
    - app.example.com
    - api.example.com

The generated secret is named <release>-itsaplan-tls. Reference it in ingress.tls.secretName and ingress.api.tls.secretName.

External database and S3

Disable the built-in PostgreSQL and give a connection string:

postgresql:
  enabled: false

externalDatabase:
  url: "postgres://user:[email protected]:5432/itsaplan"

Disable the built-in RustFS and point at an S3-compatible store:

minio:
  enabled: false

externalS3:
  endpoint: "https://s3.us-east-1.amazonaws.com"

api:
  env:
    S3_BUCKET: "my-bucket"
    S3_REGION: "us-east-1"
    S3_FORCE_PATH_STYLE: "false"

secrets:
  s3AccessKeyId: "AKIA..."
  s3SecretAccessKey: "..."

S3_FORCE_PATH_STYLE is false for AWS and Cloudflare R2, and true for RustFS.

Secrets in production

The chart creates a Kubernetes Secret with plaintext stringData. For production, keep the secrets outside the values file (Sealed Secrets, External Secrets Operator, SOPS) and inject them through a values override or a secret store CSI driver.

Upgrades

helm upgrade itsaplan charts/itsaplan -f values.yaml

Config and secret changes trigger a rolling restart on their own: the deployments carry a checksum annotation on the ConfigMap and the Secret. The new api pods apply the new migrations in their init container.

To leave the Telegram bot out, set bot.enabled: false.

Troubleshooting

Sign-in returns to the login page.

The two hostnames are not under one registrable domain, so the browser drops the session cookie. Give the api and the web app hostnames under the same domain.

Attachments fail against an external S3 store.

S3_FORCE_PATH_STYLE does not match the store. AWS and Cloudflare R2 need false, RustFS needs true.

A values change did nothing.

Only the ConfigMap and the Secret carry the checksum annotation. Run helm upgrade again and check the values reached the release with helm get values itsaplan.

Support

Other ways to run it: Railway, Docker Compose, Coolify.