mirror of
https://github.com/terraphim/gitea-infrastructure.git
synced 2026-07-16 01:00:33 +02:00
Two complementary articles: - ARTICLE-agent-task-management.md: CTO voice, ~2800 words, narrative style - ARTICLE-agent-task-consolidation.md: Technical reference, ~6000 words, full configs Both cover: beads (Go) vs br (Rust) vs Agent Mail vs Gitea with 12 labels, 1Password integration, SeaweedFS S3 storage, Docker Compose deployment, and the three API gotchas (label swap, assignees, dependencies). All commands verified against live Gitea 1.22.6. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
403 lines
22 KiB
Markdown
403 lines
22 KiB
Markdown
# We Replaced Three Agent Tools with Gitea and Twelve Labels
|
|
|
|
**Date:** February 18, 2026
|
|
**Author:** Alex, CTO @ Zestic AI
|
|
**Tags:** ai-agents, task-management, gitea, devops, simplicity
|
|
|
|
---
|
|
|
|
The best infrastructure is the infrastructure you already run.
|
|
|
|
We spent eighteen months building, rewriting, and stitching together three separate tools for AI agent task management. A Go CLI. A Rust rewrite. An MCP coordination server. Each solved a real problem. Each introduced new ones. Then one afternoon, staring at a Gitea milestone progress bar, the obvious answer arrived: we already had everything we needed. We just had too many tools to see it.
|
|
|
|
This is the story of how we replaced `bd`, `br`, and `mcp-agent-mail` with a self-hosted Gitea instance, twelve scoped labels, and a handful of shell functions. No custom servers. No proprietary state stores. No binaries to distribute. Just an issue tracker doing what issue trackers do.
|
|
|
|
## The Problem: Three Tools, One Job
|
|
|
|
Here is what our AI agent task management stack looked like before the consolidation:
|
|
|
|
**Tool 1: beads (`bd`)** -- A Go CLI that stored issues in SQLite and JSONL, committed to git inside a `.beads/` directory. Priority P0-P4, types for bug/feature/task/epic/chore. The works. It had a daemon mode, RPC mode, git hooks, and a `bd sync` command that auto-committed changes to the repository.
|
|
|
|
**Tool 2: beads_rust (`br`)** -- The Rust rewrite. Same concept, fewer footguns. Version 0.1.13. The big change: `br` never executes git commands. You run `br sync --flush-only` to export to JSONL, then `git add .beads/ && git commit` yourself.
|
|
|
|
**Tool 3: MCP Agent Mail (`mcp-agent-mail`)** -- An MCP server for multi-agent coordination. Identities, inboxes, outboxes, searchable threads, advisory file reservations (leases). Runs on `localhost:8765`. Macros for session setup, thread preparation, file reservation cycles, contact handshakes.
|
|
|
|
Three tools. Three state stores. Three sets of failure modes. One team.
|
|
|
|
## Why `bd` Auto-Commit Was a Mistake
|
|
|
|
The original beads CLI did something that sounds reasonable until you think about it for ten seconds: it auto-committed its state to git via `bd sync`.
|
|
|
|
Here is what happens when an AI agent is working on a task, writing code, staging changes, and the task tracker decides to `git add .beads/ && git commit` in the middle of that work: the agent's uncommitted changes either get swept into the beads commit or the beads commit races with the agent's own commit. We lost work. Not once -- multiple times.
|
|
|
|
The Rust rewrite fixed this by being "non-invasive." `br` never touches git. That was the right call, and it taught us an important principle: **your task tracker has no business modifying the repository it lives in.** Task state and code changes operate at different cadences. Coupling them through auto-commit is asking for data loss.
|
|
|
|
But `br` still had the fundamental problem of being a local-first tool. Data lived in `.beads/` inside each repository. No cross-repo visibility. No web UI for humans. Every machine that needed to manage tasks required the `br` binary installed. For a single developer working on a single repo, this is fine. For a team running multiple AI agents across multiple repositories, it is untenable.
|
|
|
|
## Agent Mail Solved Coordination, Then Became the Problem
|
|
|
|
Enter `mcp-agent-mail`. It solved a genuine gap: how do multiple AI agents coordinate work without clobbering each other?
|
|
|
|
The answer was a message bus with identity management and file reservations. Agent A reserves `src/auth.rs`, sends a message to Agent B saying "I am working on auth, do not touch this file," and Agent B acknowledges. Thread-based communication. Advisory locking. Cross-repo coordination through a central server.
|
|
|
|
It worked. But look at what an agent had to do before it could start actual work:
|
|
|
|
1. Register an identity with the mail server
|
|
2. Start a session
|
|
3. Prepare a thread
|
|
4. Reserve files it intended to modify
|
|
5. Send a contact handshake to relevant agents
|
|
6. Check inbox for acknowledgments
|
|
7. Actually start working
|
|
|
|
Seven steps before writing a single line of code. That is not coordination -- that is ceremony.
|
|
|
|
And the mail server itself was another piece of infrastructure to run, monitor, and keep alive. Its state store was separate from the task tracker. You had task state in `.beads/`, coordination state in `mcp-agent-mail`, and code state in git. Three sources of truth that needed to stay synchronized. They didn't.
|
|
|
|
## The Moment of Clarity
|
|
|
|
The realization was almost embarrassing in its simplicity.
|
|
|
|
We were already running a self-hosted Gitea instance at `git.terraphim.cloud` -- Gitea 1.22.6, Docker Compose, PostgreSQL, SeaweedFS for S3 storage, Caddy reverse proxy, the whole stack. We had deployed it for code hosting. It had an issue tracker. That issue tracker had labels, milestones, assignees, dependencies, comments, and a REST API.
|
|
|
|
Every feature we had built across three tools already existed in the software we were running for something else.
|
|
|
|
| What We Built | What Gitea Already Has |
|
|
|---------------|----------------------|
|
|
| `br create` / `bd create` | `POST /repos/{owner}/{repo}/issues` |
|
|
| `br ready` / `br update --status` | Scoped workflow labels |
|
|
| `br list --status ready` | `GET /issues?labels=workflow:ready` |
|
|
| `br close` | `PATCH /issues/{index}` with `{"state": "closed"}` |
|
|
| `br dep add` | `POST /issues/{index}/dependencies` |
|
|
| `br stats` | Milestone progress (open/closed counts) |
|
|
| Agent Mail file reservations | Issue assignees (one owner per task) |
|
|
| Agent Mail threads | Issue comments |
|
|
| Agent Mail identities | Gitea user accounts |
|
|
| `.beads/` JSONL export | Gitea API (queryable, no export needed) |
|
|
|
|
The mapping was nearly one-to-one. The features we thought we needed to build were just an issue tracker with labels.
|
|
|
|
## The Implementation: Twelve Labels and Five Shell Functions
|
|
|
|
The entire implementation fits in three files. A 130-line setup script. A 480-line skill document. A 430-line integration test suite. That is it.
|
|
|
|
### The Label Scheme
|
|
|
|
Twelve scoped labels across three dimensions. Workflow state, priority, and type. Each scope is mutually exclusive -- you cannot be both `workflow:ready` and `workflow:in-progress` at the same time.
|
|
|
|
**Workflow (4 labels):**
|
|
|
|
| Label | Color | Meaning |
|
|
|-------|-------|---------|
|
|
| `workflow:ready` | Green | Available for an agent to claim |
|
|
| `workflow:in-progress` | Yellow | Being actively worked on |
|
|
| `workflow:blocked` | Red | Waiting on a dependency |
|
|
| `workflow:done` | Purple | Complete |
|
|
|
|
**Priority (4 labels):**
|
|
|
|
| Label | Color | Meaning |
|
|
|-------|-------|---------|
|
|
| `priority:critical` | Dark red | Now |
|
|
| `priority:high` | Red | This cycle |
|
|
| `priority:medium` | Yellow | Normal |
|
|
| `priority:low` | Green | When we get to it |
|
|
|
|
**Type (4 labels):**
|
|
|
|
| Label | Color | Meaning |
|
|
|-------|-------|---------|
|
|
| `type:feature` | Blue | New functionality |
|
|
| `type:bug` | Red | Something broke |
|
|
| `type:task` | Violet | General work item |
|
|
| `type:research` | Teal | Investigation / spike |
|
|
|
|
Setup is idempotent. Run `setup-labels.sh` as many times as you want -- it checks for existing labels before creating:
|
|
|
|
```bash
|
|
source ~/op_zesticai_non_prod.sh
|
|
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
|
|
./setup-labels.sh # defaults to terraphim/agent-tasks
|
|
./setup-labels.sh myorg myrepo # or specify your own
|
|
```
|
|
|
|
### The Shell Helpers
|
|
|
|
Every operation is a function that takes positional arguments and returns JSON. No flags to remember, no binary to install. Copy the function into your agent's context and go.
|
|
|
|
The base API helper:
|
|
|
|
```bash
|
|
gitea_api() {
|
|
local method="$1" endpoint="$2" data="${3:-}"
|
|
local args=(-s -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json")
|
|
if [ -n "$data" ]; then
|
|
args+=(-X "$method" -d "$data")
|
|
else
|
|
args+=(-X "$method")
|
|
fi
|
|
curl "${args[@]}" "$GITEA_URL/api/v1$endpoint"
|
|
}
|
|
```
|
|
|
|
Create a project (milestones serve as project containers -- more on why below):
|
|
|
|
```bash
|
|
gitea_create_project "Sprint 2026-W08" "Weekly sprint" "2026-02-28T00:00:00Z"
|
|
```
|
|
|
|
Create a task and assign it to a milestone:
|
|
|
|
```bash
|
|
gitea_create_task "Implement auth module" "OAuth2 flow for API clients" 1 "4,14"
|
|
```
|
|
|
|
Claim a task (swap workflow label + assign):
|
|
|
|
```bash
|
|
gitea_claim 1 root
|
|
```
|
|
|
|
Close a task with a comment:
|
|
|
|
```bash
|
|
gitea_close_task 1 "Implemented and tested. See commit abc123."
|
|
```
|
|
|
|
Check project progress:
|
|
|
|
```bash
|
|
gitea_progress 1
|
|
# Returns: {"milestone":"Sprint 2026-W08","open":3,"closed":7,"total":10,"percent_complete":70}
|
|
```
|
|
|
|
An AI agent can learn the entire task management API from a single skill document. No SDK installation. No daemon to keep running. No state synchronization. The state lives in Gitea, which is already running, already backed up, already monitored.
|
|
|
|
### Milestones as Projects
|
|
|
|
Gitea 1.22.6 has no project board REST API. This is not speculation -- we tested it. `GET /api/v1/user/projects` returns 404. The feature is tracked in Gitea issue #14299, targeted for version 1.26+.
|
|
|
|
This turned out to be a non-issue. Milestones give us everything a project container needs: a title, a description, a due date, automatic progress tracking (open vs. closed issue counts), and the ability to filter issues by milestone. You do not need kanban columns when you have workflow labels.
|
|
|
|
## The Infrastructure: Already Running
|
|
|
|
The Gitea instance was deployed via Docker Compose with 1Password template injection. No new infrastructure was required for this migration. Zero.
|
|
|
|
```
|
|
Internet -> Caddy (HTTPS) -> Gitea (localhost:3000)
|
|
|
|
|
Docker Network (internal)
|
|
| | |
|
|
PostgreSQL SeaweedFS Prometheus
|
|
(S3)
|
|
```
|
|
|
|
Secrets are managed through 1Password's `op inject`:
|
|
|
|
```bash
|
|
op inject --in-file docker-compose.yml.template --out-file docker-compose.yml
|
|
docker compose up -d
|
|
```
|
|
|
|
The template contains `op://TerraphimPlatform/...` references that get resolved at deploy time. No plaintext secrets in git. No `.env` files to manage. Database password, S3 keys, API tokens -- all in one vault, all injected at runtime.
|
|
|
|
SeaweedFS provides S3-compatible storage for attachments, avatars, and Git LFS objects. Prometheus scrapes metrics from all services. Caddy handles TLS termination with automatic certificate renewal. The whole stack is six Docker containers and a Caddyfile.
|
|
|
|
For agent authentication, the token lives in 1Password:
|
|
|
|
```bash
|
|
export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential")
|
|
```
|
|
|
|
One line. No token files. No environment variable sprawl. Every agent session starts with this, and the token never touches disk.
|
|
|
|
## The Gotchas Nobody Tells You About
|
|
|
|
This is the section that justifies the article. Gitea's API is good, but it has sharp edges that cost us a day of debugging. Learn from our pain.
|
|
|
|
### Gotcha 1: Scoped Label Exclusivity Is UI-Only
|
|
|
|
This was the single biggest surprise.
|
|
|
|
Gitea's scoped labels (created with `exclusive: true`) display mutual exclusivity in the web UI. Click `workflow:in-progress` and `workflow:ready` disappears. Beautiful. Exactly what we wanted.
|
|
|
|
But the API `POST /repos/{owner}/{repo}/issues/{index}/labels` endpoint just **appends**. It does not remove conflicting scoped labels. You can end up with both `workflow:ready` and `workflow:in-progress` on the same issue, which is nonsensical.
|
|
|
|
The fix is a swap pattern: fetch current labels, filter out the old scope, add the new label, and PUT the complete set atomically:
|
|
|
|
```bash
|
|
gitea_swap_workflow() {
|
|
local index="$1" new_label="$2" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}"
|
|
|
|
# Get the ID of the new workflow label
|
|
repo_labels=$(gitea_api GET "/repos/$owner/$repo/labels")
|
|
new_id=$(echo "$repo_labels" | jq -r --arg n "$new_label" \
|
|
'.[] | select(.name==$n) | .id')
|
|
|
|
# Get current issue labels, filtering out all workflow:* labels
|
|
issue=$(gitea_api GET "/repos/$owner/$repo/issues/$index")
|
|
kept_ids=$(echo "$issue" | jq \
|
|
'[.labels[] | select(.name | startswith("workflow:") | not) | .id]')
|
|
|
|
# Combine kept labels + new workflow label, PUT atomically
|
|
all_ids=$(echo "$kept_ids" | jq --argjson nid "$new_id" '. + [$nid]')
|
|
gitea_api PUT "/repos/$owner/$repo/issues/$index/labels" "{\"labels\": $all_ids}"
|
|
}
|
|
```
|
|
|
|
This pattern -- GET current state, compute desired state, PUT the complete set -- is the only reliable way to manage scoped labels via the API. We wrap every workflow transition in this function. Our integration tests verify that the old workflow label actually gets removed on every transition.
|
|
|
|
### Gotcha 2: The Assignee Endpoint Does Not Exist
|
|
|
|
The Gitea docs suggest `POST /repos/{owner}/{repo}/issues/{index}/assignees` should work. In Gitea 1.22.6, it returns 404.
|
|
|
|
Use `PATCH /repos/{owner}/{repo}/issues/{index}` with `{"assignees": ["username"]}` instead. It is the general issue edit endpoint, not a dedicated assignee endpoint, but it works.
|
|
|
|
```bash
|
|
# Wrong (404 in Gitea 1.22.6):
|
|
gitea_api POST "/repos/$owner/$repo/issues/$index/assignees" '{"assignees": ["root"]}'
|
|
|
|
# Right:
|
|
gitea_api PATCH "/repos/$owner/$repo/issues/$index" '{"assignees": ["root"]}'
|
|
```
|
|
|
|
### Gotcha 3: Issue Dependencies Use IssueMeta Format
|
|
|
|
If you try the obvious body format for adding a dependency:
|
|
|
|
```json
|
|
{"depends_on_id": 2}
|
|
```
|
|
|
|
You get: `"repository does not exist [id: 0]"`. Helpful error message, that.
|
|
|
|
The correct format is IssueMeta -- a full reference to the issue including owner and repo:
|
|
|
|
```json
|
|
{"owner": "terraphim", "repo": "agent-tasks", "index": 2}
|
|
```
|
|
|
|
This is actually a better design than a bare ID. It supports cross-repository dependencies out of the box. But the error message when you get it wrong is genuinely misleading.
|
|
|
|
## Testing: 26 Assertions, Zero Mocks
|
|
|
|
We do not mock API calls. Every test runs against the live Gitea instance. The test suite creates a timestamped repository (`skill-test-{timestamp}`), runs all ten test scenarios, validates 26 assertions, and cleans up the repo on exit -- even on failure, thanks to a bash trap.
|
|
|
|
```bash
|
|
./test-gitea-skill.sh
|
|
```
|
|
|
|
```
|
|
============================================
|
|
Gitea Skill Integration Tests
|
|
============================================
|
|
|
|
Instance: https://git.terraphim.cloud
|
|
User: root
|
|
Test repo: root/skill-test-1739884800
|
|
|
|
--- Test 1: Create test repository ---
|
|
[PASS] Repo name matches
|
|
[PASS] Repo has id
|
|
[PASS] Owner matches
|
|
--- Test 2: Create labels via setup-labels.sh ---
|
|
[PASS] Created 12 labels (expected >= 12)
|
|
[PASS] workflow:ready is exclusive
|
|
[PASS] workflow:in-progress is exclusive
|
|
...
|
|
--- Test 6: Claim task (workflow:in-progress) ---
|
|
[PASS] Issue has workflow:in-progress label
|
|
[PASS] workflow:ready removed by label swap
|
|
[PASS] Assignee set
|
|
...
|
|
|
|
============================================
|
|
Results: 26 passed, 0 failed (26 total)
|
|
============================================
|
|
```
|
|
|
|
Test 6 is the critical one. It verifies three things in sequence: the new workflow label is present, the old workflow label was removed by the swap pattern, and the assignee was set via PATCH. If any of these fail, we know the gotchas are back.
|
|
|
|
First run of this test suite against the live API: 23/26 passed, 3 failed. Those three failures were the label swap bug and the assignee endpoint. Fixed in an hour. Second run: 26/26.
|
|
|
|
That is what testing against real infrastructure gives you. Mocks would have passed on the first run and failed in production.
|
|
|
|
## What We Lost and Why We Don't Miss It
|
|
|
|
Let's be honest about what the migration removed.
|
|
|
|
**Gone: Local-first offline capability.** The `.beads/` directory worked without network access. Gitea requires connectivity. For our use case -- AI agents running on servers with reliable internet -- this is not a loss. If your agents work offline in a bunker, this trade-off may not suit you.
|
|
|
|
**Gone: `br sync` JSONL export.** The beads tools exported task data as JSONL files you could grep, pipe, and process with standard Unix tools. With Gitea, you query the API. For an AI agent that speaks HTTP natively, this is arguably better. For a human who wants to `grep -c "status:done" .beads/issues.jsonl`, it is different. We have not missed it.
|
|
|
|
**Gone: Agent Mail file reservations.** The advisory locking system in `mcp-agent-mail` prevented two agents from editing the same file simultaneously. We replaced this with a simpler convention: one agent per task, one task per file group. If Agent A is assigned to issue #3 and issue #3's description lists the files it will modify, Agent B checks the issue board before starting work. Is this as rigorous as advisory locks? No. Has it caused a conflict in practice? Also no.
|
|
|
|
**Gone: Daemon mode and RPC.** The original `bd` had a long-running daemon for performance. With Gitea, every operation is a stateless HTTP call. No daemon to crash. No RPC to debug. No port conflicts.
|
|
|
|
**Gained: Web UI for humans.** This was the biggest win we did not plan for. Stakeholders can now see task status in a browser. They can add comments, change priorities, and close issues without learning a CLI. The task board became a communication tool, not just an agent bookkeeping system.
|
|
|
|
**Gained: Cross-repo visibility.** All agent tasks live in `terraphim/agent-tasks` by default. One place to see everything. Override with `GITEA_OWNER` and `GITEA_REPO` environment variables when you need per-repo tracking.
|
|
|
|
**Gained: Zero distribution burden.** No binary to compile. No package to install. The agent skill is a markdown document with shell functions. Copy, paste, authenticate, work.
|
|
|
|
## The br Command Mapping
|
|
|
|
For anyone migrating from `br`, here is the translation table:
|
|
|
|
| br Command | Gitea Equivalent | Notes |
|
|
|------------|------------------|-------|
|
|
| `br init` | `./setup-labels.sh` | One-time per repo |
|
|
| `br create "Title" --type task` | `gitea_create_task "Title" "Body" $MILESTONE_ID "$LABEL_IDS"` | Labels by ID |
|
|
| `br ready 1` | `gitea_ready 1` | Swap pattern under the hood |
|
|
| `br update 1 --status in_progress` | `gitea_claim 1 root` | Also sets assignee |
|
|
| `br close 1` | `gitea_close_task 1 "Done"` | Closes issue + sets workflow:done |
|
|
| `br list --status ready` | `curl ... /issues?labels=workflow:ready` | Filter by label |
|
|
| `br dep add 3 2` | `POST /issues/3/dependencies {"owner","repo","index":2}` | IssueMeta format |
|
|
| `br stats` | `gitea_progress $MILESTONE_ID` | Returns JSON with percentages |
|
|
| `br sync --flush-only` | Not needed | State already in Gitea |
|
|
|
|
The last row is the point. There is no sync step because there is nothing to sync. The source of truth is Gitea. There is no `.beads/` directory to commit. There is no JSONL file to export. The state lives in the database of a server that is already running and already backed up.
|
|
|
|
## What This Means for AI Agent Architecture
|
|
|
|
This migration crystallized something we had been circling for a while: **AI agents do not need bespoke tooling. They need standard tooling with good APIs.**
|
|
|
|
An AI agent that can make HTTP calls can use Gitea's REST API. It does not need a custom CLI. It does not need an MCP server wrapping another API. It does not need a local database synced to git. It needs `curl`, `jq`, and a token.
|
|
|
|
The twelve-label scheme gives agents a state machine they can reason about. `workflow:ready` means "pick this up." `workflow:in-progress` means "someone is on it." `workflow:blocked` means "wait." `workflow:done` means "move on." Any LLM can understand this. No specialized training, no tool-specific prompting.
|
|
|
|
Milestones give agents project context. "You are working on Sprint 2026-W08. Here are the open tasks. Here is what is blocked. Here is overall progress." All queryable via a single API call.
|
|
|
|
Issue comments give agents memory. Close a task with a comment explaining what you did, what you learned, what edge cases you hit. The next agent picks up the thread.
|
|
|
|
Dependencies give agents sequencing. "Do not start issue #5 until issue #3 is closed." The API enforces it.
|
|
|
|
This is not revolutionary. It is an issue tracker. That is exactly the point. The best agent infrastructure is boring infrastructure that works.
|
|
|
|
## What's Next
|
|
|
|
Three things we are evaluating:
|
|
|
|
**1. MCP integration.** The official `gitea-mcp` server provides ~80 tools for repository management but lacks milestone and workflow label support. The `forgejo-mcp` alternative covers milestones and dependencies. We may adopt one of these as a complement to our shell helpers, but not as a replacement. The helpers handle the 20% of operations that no MCP server covers yet.
|
|
|
|
**2. Gitea 1.26 upgrade.** When the project board API ships, we will evaluate whether it adds value over our milestone + label approach. My bet: it will not. We don't need kanban columns when we have four workflow labels and a REST API.
|
|
|
|
**3. Cross-instance agent coordination.** Right now, all agents share one Gitea instance. As we scale, we may need agents on different instances to coordinate. Issue dependencies already support cross-repo references (`{"owner": "...", "repo": "...", "index": N}`). Cross-instance is a harder problem, but we are not solving problems we don't have yet.
|
|
|
|
## The Bottom Line
|
|
|
|
We ran three tools because we assumed agent task management was a special problem that needed special solutions. It isn't.
|
|
|
|
An issue tracker with labels is a task management system. A milestone is a project. An assignee is an owner. A comment is a status update. A dependency is a dependency. We had been building elaborate machinery to replicate what a 2004-vintage Bugzilla could do.
|
|
|
|
Gitea, twelve labels, and five shell functions. The test suite passes. The agents are productive. Stakeholders can see the board. The infrastructure team is not maintaining three extra tools. The answer, as it so often is, was less code -- not more.
|
|
|
|
---
|
|
|
|
**Repository:** [terraphim/gitea-infrastructure](https://git.terraphim.cloud/terraphim/gitea-infrastructure)
|
|
**Skill Document:** `.agents/skills/gitea/SKILL.md`
|
|
**Setup Script:** `.agents/skills/gitea/setup-labels.sh`
|
|
**Integration Tests:** `.agents/skills/gitea/test-gitea-skill.sh` (10 tests, 26 assertions, all passing)
|
|
**Gitea Version:** 1.22.6
|
|
**Stack:** Docker Compose + PostgreSQL 15 + SeaweedFS + Caddy + 1Password + Prometheus
|