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
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
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')
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
This is the kind of detail that makes automation reliable and future‑you grateful.
Top comments (0)