DEV Community

Cover image for Privacy-first mind mapping app. Part 7: Owning Your Workflow
Kornel Maraz
Kornel Maraz

Posted on

Privacy-first mind mapping app. Part 7: Owning Your Workflow

Most posts about self‑hosting or indie projects drift into marketing speak. This one doesn’t. This is simply how I build and ship MindMapVault — the real workflow, the real tools, and the real reasons behind them.

This chapter is about ownership. Not in the corporate sense, but in the “I want to understand and control my own tools” sense. If you’re building something solo, or experimenting with automation, or trying to level up your confidence as a developer, this might resonate.

Why I Self‑Host My Git Forge

I run a self‑hosted Forgejo instance on my NAS with a runner. Not because GitHub or GitLab are bad — they’re great — but because I know myself.

Sometimes I move fast.

Sometimes I forget things.

Sometimes I push .env values straight into a repo.

Keeping the forge and runner physically close to me solves that. It gives me:

  • control (no accidental secrets leaving my network)
  • convenience (fast CI, predictable environment)
  • confidence (I can experiment without worrying about leaking something)

And honestly? I just like the project. Forgejo is clean, lightweight, and fits the “local‑first” philosophy behind MindMapVault.

What I use it for

  • another Git remote
  • CI/CD workflows
  • automated checks
  • release builds
  • experiments that I don’t want to run on a public cloud

It’s not about paranoia. It’s about craftsmanship. When you own the forge, you own the workflow.

Automation Makes You a Better Developer (Especially Solo)

When you’re working alone, automation isn’t a luxury — it’s a survival mechanism.

My Forgejo runner handles:

  • type‑checking
  • offline‑parity checks
  • desktop build pipelines
  • release asset uploads
  • version bumping
  • changelog generation

This means I can focus on the actual product instead of the glue.

And here’s the interesting part:
AI tools like GitHub Copilot Agents changed the way I commit.

Not by writing code for me — but by making me more disciplined:

  • commits became larger but more coherent
  • changelogs became more structured
  • documentation became essential, not optional
  • project planning became a real process, not a TODO file

When AI helps you move faster, the meta‑work (planning, documenting, structuring) becomes even more important. Otherwise you drown in your own velocity.

Production Hosting: Simple, Predictable, Mine

I host production on a Czech community VPS: vpsFree.cz. It’s stable, affordable, and gives me the right balance of control and simplicity.

The setup is intentionally minimal:

  • backend (Rust)
  • Postgres
  • MinIO
  • Cloudflare Tunnel
  • Cloudflare Pages for the frontend

All running via a single docker-compose.yml, all credentials in a single .env file. when you are ready zyou just run docker compose up -d and in 6 seconds the new version, fix is up in production.

# ─────────────────────────────────────────────────────────────────────────────
# MindMapVault — local dev compose
# Manages the backend plus the local RustFS and PostgreSQL dependencies.
# Use `docker compose up` / `docker compose up --build`.
# ─────────────────────────────────────────────────────────────────────────────
services:
  minio:
    image: minio/minio:latest
    container_name: mindmapvault-minio
    restart: unless-stopped
    networks:
      - cryptmind
    ulimits:
      nofile:
        soft: 65535
        hard: 65535
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
      MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
    ports:
      - "${MINIO_API_PORT:-9000}:9000"
      - "${MINIO_CONSOLE_PORT:-9001}:9001"
    volumes:
      - minio-data:/data
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/minio/health/live >/dev/null || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 10s

  postgres:
    image: postgres:16
    container_name: mindmapvault-postgres
    restart: unless-stopped
    networks:
      - cryptmind
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
      POSTGRES_DB: ${POSTGRES_DB:-cryptmind}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-cryptmind}"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 10s

  backend:
    build:
      context: .
      dockerfile: backend/Dockerfile.local
    container_name: mindmapvault-backend
    restart: unless-stopped
    networks:
      - cryptmind
    depends_on:
      minio:
        condition: service_healthy
      mongodb:
        condition: service_healthy
      postgres:
        condition: service_healthy
    ports:
      - "${BACKEND_PORT:-8090}:8090"
    volumes:
      - stoolap-data:/data
    environment:
      HOST: "0.0.0.0"
      PORT: "8090"
      MALLOC_ARENA_MAX: "2"
      MALLOC_TRIM_THRESHOLD_: "131072"
      MALLOC_MMAP_THRESHOLD_: "131072"
      MALLOC_CONF: ${MALLOC_CONF:-background_thread:true,narenas:1,dirty_decay_ms:0,muzzy_decay_ms:0,metadata_thp:disabled}
      DB_ENGINE: ${DB_ENGINE:-mongodb}
      POSTGRES_DSN: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-cryptmind}
      MINIO_ENDPOINT: http://minio:9000
      MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
      MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
      MINIO_BUCKET: ${MINIO_BUCKET:-mindmapvault-maps}
      MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
      MINIO_REGION: ${MINIO_REGION:-us-east-1}
      MINIO_PRESIGN_EXPIRY_SECS: ${MINIO_PRESIGN_EXPIRY_SECS:-3600}
      JWT_SECRET: ${JWT_SECRET}
      JWT_ACCESS_EXPIRY_SECS: ${JWT_ACCESS_EXPIRY_SECS:-900}
      JWT_REFRESH_EXPIRY_SECS: ${JWT_REFRESH_EXPIRY_SECS:-2592000}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
      STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
      STRIPE_PRICE_PAID_YEARLY_ID: ${STRIPE_PRICE_PAID_YEARLY_ID:-}
      STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-https://mindmapvault.com/vaults}
      STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-https://mindmapvault.com/vaults}
      STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-https://mindmapvault.com/vaults}
      DISABLE_APP_LOGIN_TURNSTILE: ${DISABLE_APP_LOGIN_TURNSTILE:-false}
      ENABLE_DIAGNOSTICS_ROUTES: ${ENABLE_DIAGNOSTICS_ROUTES:-false}
      ENABLE_PPROF: ${ENABLE_PPROF:-false}
      PPROF_DURATION_SECS: ${PPROF_DURATION_SECS:-90}
      CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://tauri.localhost,https://tauri.localhost}
      RUST_LOG: ${RUST_LOG:-backend=info,tower_http=info}
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:8090/health || exit 1"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 10s

cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: mindmapvault-cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    depends_on:
      backend:
        condition: service_healthy
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}
    networks:
      - mindmapvault-network

volumes:
  minio-data:
    name: cryptmind-minio-data
  postgres-data:
    name: cryptmind-postgres-data

networks:
  cryptmind:
    name: cryptmind-network
Enter fullscreen mode Exit fullscreen mode

then the docker ps looks like this:

CONTAINER ID   NAME                       CPU %     MEM USAGE / LIMIT   MEM %     NET I/O           BLOCK I/O   PIDS
468768ef5c79   mindmapvault-backend       0.00%     31.55MiB / 4GiB     0.77%     26.7MB / 4.73MB   0B / 0B     9
06c14906524a   mindmapvault-postgres      0.00%     31.82MiB / 4GiB     0.78%     2.17MB / 28.7MB   0B / 0B     7
d52f573bcdef   mindmapvault-minio         3.68%     86.56MiB / 4GiB     2.11%     3.65MB / 10.7MB   0B / 0B     13
27fcb47af8c7   mindmapvault-cloudflared   0.19%     16.31MiB / 4GiB     0.40%     132MB / 212MB     0B / 0B     13
Enter fullscreen mode Exit fullscreen mode

No Azure.

No Firebase.

No vendor lock‑in.

Just a clean, reproducible, auditable setup.

Open‑Sourcing the Local‑First Version

And when I have what to show then I pushed the FOSS version of MindMapVault here:

https://github.com/mindmapvault/mindmapvault-foss

It’s a local‑first, privacy‑focused, offline‑capable mind‑mapping desktop app.

  • No cloud.
  • No telemetry.
  • No account.

If you review the code and see weak spots, please comment directly. I welcome criticism — it’s the only way a solo project grows.

GitHub Wiki & Pages — Free Tools Many Developers Underuse

The GitHub Wiki is a perfect home for these research notes once they mature.
It’s Markdown-native, versioned, and acts as a clean documentation hub.
I even use it as a lightweight blog for deeper technical posts.
GitHub Pages hosts the interactive MindMapVault demo — free, fast, and frictionless.
Together, Wiki + Pages + Actions + runners make GitHub a full publishing and documentation platform.

What this gives you for free:

  • Wiki for architecture docs
  • Pages for interactive JS demos
  • Actions for CI
  • Runners for automation
  • A complete developer ecosystem

Research First — Write It Down Before You Build

Before implementing any feature, I create a small Markdown mockup in research/ or notes/.
It’s not a spec — just a quick outline of goals, constraints, and what “good” looks like.
This keeps the project coherent and gives Copilot the right context.
Even a rough sketch prevents future architectural mistakes.
For MindMapVault, every complex feature starts as a simple .md file.

Why this helps:

  • clarifies goals early
  • reduces rework
  • gives Copilot better context
  • documents decisions
  • keeps ideas discoverable
Goals
- Allow arbitrary file attachments (many per map) without transferring plaintext to server in encrypted mode.
- Use per-map buckets or per-map prefixes to keep attachments discoverable and scannable.
- Lazy-download UX: file blobs are fetched only when the user requests/downloads them.
- Provide secure presigned URLs for direct client upload/download while the server maintains metadata, access control, and versioning.

High-level design
- Storage layout (S3/MinIO recommended):
  - Bucket-per-map: `cryptmind-<map-id>` OR
  - Single bucket + prefix: `maps/<map-id>/attachments/<attachment-uuid>` (preferred if many buckets are undesirable)
- Object keys: `attachments/<attachment-uuid>/<sanitized-filename>` or `attachments/<timestamp>-<uuid>-<name>`
- Versioning: enable S3 versioning where possible; store `s3_version_id` in DB on complete.

Security & encryption
- Encrypted mode (server never sees plaintext or DEKs):
  - Client-side: encrypt attachment (AES-GCM or AES-SIV) on the client using the same client-side DEK management as mindmaps.
  - Send `encrypted: true` and minimal `encryption_metadata` (algorithm, non-secret IV/nonce, tag if detached) to server when initiating upload. Do NOT send DEKs.
  - Server returns presigned upload URL or accepts proxied upload; backend stores opaque object and writes metadata to DB.
  - On download, server issues presigned GET; client downloads then decrypts locally.
- Plaintext mode: files uploaded without encryption; server may perform virus scanning or content checks per policy.

Database schema (maybe)
- attachments
  - id: UUID (PK)
  - map_id: UUID (FK)
  - node_id: UUID (nullable) — attach to a node/note if applicable
  - name: TEXT (original filename)
  - sanitized_name: TEXT
  - content_type: TEXT
  - size_bytes: INTEGER
  - s3_key: TEXT
  - s3_version_id: TEXT (nullable)
  - uploaded_by: UUID (user id)
  - uploaded_at: TIMESTAMP
  - encrypted: BOOLEAN
  - encryption_meta: JSON (algorithm, iv, tag — non-secret values only)
  - checksum_sha256: TEXT (optional)
  - status: ENUM('pending','available','deleted')
Enter fullscreen mode Exit fullscreen mode

Example: A Real Changelog Entry

This is what a typical release looks like now — structured, explicit, and automation‑friendly:

**[0.3.27] – 2026‑05‑03**

**Changed**
- Editor UX / Node Icons — Restored full node icon workflow in `MindMapEditor.tsx`:
  - toolbar icon picker
  - context‑menu icon action
  - keyboard shortcut `I`
  - inline icon rendering
  - multi‑select icon toggling
  - help panel + status bar hints
- Editor Components — Added reusable icon picker infrastructure (`MindMapIconPicker.tsx`, `DynamicLucideIcon.tsx`).
- CI / Release Automation — Modernized `.github/workflows/desktop-build.yml`:
  - upgraded actions
  - replaced deprecated upload steps
  - switched to Corepack for `pnpm`
  - kept `$GITHUB_OUTPUT` usage

**Validation**
- `pnpm exec tsc --noEmit` → clean
- `node scripts/check_frontend_offline_parity.mjs` → passed
Enter fullscreen mode Exit fullscreen mode

This is the kind of detail that makes automation reliable and future‑you grateful.

Top comments (0)