Real-World Scenario: Minecraft Server Orchestration
The Problem
Running a Minecraft (Forge) server fleet in Docker containers creates an observability gap:
- Logs are written to
stdoutinside the container — ephemeral and unstructured - Crash detection requires either polling containers or running a separate monitoring daemon
- Alerting to Discord requires deploying a bot (OAuth, permissions, token management)
- Scaling to multiple Forge instances multiplies all of the above
The typical homelab solution involves 3–5 separate tools (Prometheus, Grafana, a Discord bot, log aggregators). That's a dependency chain that breaks.
The TinyMQ Solution
A lightweight log collector sidecar reads each server's stdout, parses events, and publishes them to TinyMQ. TinyMQ's native Webhook feature delivers crash alerts directly to Discord's Incoming Webhook URL — no Discord bot required.
Architecture
Step 1: Register the Discord Webhook
Discord provides native Incoming Webhook URLs for channels — no bot required, just a URL.
In Discord: Channel Settings → Integrations → Webhooks → New Webhook → Copy URL
Register it with TinyMQ:
curl -X POST http://localhost:7800/webhook/mc.crashes \
-H "Content-Type: application/json" \
-d '{"url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"}'
Now any message published to mc.crashes will be POSTed directly to Discord's webhook endpoint.
Discord webhooks expect a specific JSON payload with a content field (or embeds). The log collector worker must format the TinyMQ payload accordingly before publishing to mc.crashes.
Step 2: Log Collector Sidecar (Go)
This sidecar reads the Minecraft server's log file (or Docker log stream), classifies events, and publishes them to TinyMQ.
package main
import (
"bufio"
"encoding/json"
"log"
"os"
"strings"
"time"
"github.com/x-name15/tinymq/client"
)
type LogEvent struct {
ServerID string `json:"server_id"`
Level string `json:"level"` // "INFO", "WARN", "ERROR", "CRASH"
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
func classifyLine(line string) string {
upper := strings.ToUpper(line)
switch {
case strings.Contains(upper, "CRASH") || strings.Contains(upper, "FATAL"):
return "CRASH"
case strings.Contains(upper, "ERROR") || strings.Contains(upper, "EXCEPTION"):
return "ERROR"
case strings.Contains(upper, "WARN"):
return "WARN"
default:
return "INFO"
}
}
func main() {
serverID := os.Getenv("SERVER_ID") // e.g., "forge-survival-1"
logPath := os.Getenv("LOG_PATH") // e.g., "/logs/latest.log"
brokerURL := os.Getenv("TINYMQ_URL")
if brokerURL == "" {
brokerURL = "http://tinymq:7800"
}
mq := client.NewClient(brokerURL)
f, err := os.Open(logPath)
if err != nil {
log.Fatalf("Cannot open log file: %v", err)
}
defer f.Close()
// Seek to end — only new lines
f.Seek(0, 2)
log.Printf("[%s] Collector started. Tailing %s...\n", serverID, logPath)
scanner := bufio.NewScanner(f)
for {
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
event := LogEvent{
ServerID: serverID,
Level: classifyLine(line),
Message: line,
Timestamp: time.Now(),
}
payload, _ := json.Marshal(event)
// Publish all logs to general topic
mq.Publish("mc.logs", payload)
// Escalate crashes to dedicated topic (which has Discord webhook)
if event.Level == "CRASH" || event.Level == "ERROR" {
// Format for Discord
discordPayload, _ := json.Marshal(map[string]string{
"content": "🚨 **[" + serverID + "]** Server Event `" + event.Level + "`:\n```\n" + line + "\n```",
})
mq.Publish("mc.crashes", discordPayload)
}
}
// File hasn't grown — wait 500ms before next scan
time.Sleep(500 * time.Millisecond)
}
}
Step 3: Docker Compose — Full Stack
# docker-compose.yml
services:
tinymq:
image: flez71/tinymq:latest
ports:
- "7800:7800"
volumes:
- ./data:/root/data
restart: unless-stopped
# Minecraft Forge Server 1
mc-survival:
image: itzg/minecraft-server
environment:
- EULA=TRUE
- TYPE=FORGE
- VERSION=1.20.1
- MEMORY=4G
ports:
- "25565:25565"
volumes:
- ./servers/survival:/data
restart: unless-stopped
# Log Collector Sidecar for mc-survival
collector-survival:
build: ./collector
environment:
- SERVER_ID=forge-survival-1
- LOG_PATH=/logs/latest.log
- TINYMQ_URL=http://tinymq:7800
volumes:
- ./servers/survival/logs:/logs:ro # Read-only access to server logs
depends_on:
- tinymq
- mc-survival
restart: unless-stopped
# Minecraft Forge Server 2
mc-creative:
image: itzg/minecraft-server
environment:
- EULA=TRUE
- TYPE=FORGE
- VERSION=1.20.1
- MEMORY=2G
ports:
- "25566:25565"
volumes:
- ./servers/creative:/data
restart: unless-stopped
collector-creative:
build: ./collector
environment:
- SERVER_ID=forge-creative-1
- LOG_PATH=/logs/latest.log
- TINYMQ_URL=http://tinymq:7800
volumes:
- ./servers/creative/logs:/logs:ro
depends_on:
- tinymq
- mc-creative
restart: unless-stopped
Step 4: Monitor from CLI
# See all active queues — how many logs are pending?
tmq status
# Peek at the latest crash events without consuming them
tmq peek mc.crashes --limit=5
# Live-tail all log events in real-time
tmq tail mc.logs
# Check if any server crashed in the last hour
tmq sub mc.crashes --limit=20 --auto-ack=false --timeout=1s
What You Get
| Capability | Implementation |
|---|---|
| Unified log stream | All servers publish to mc.logs |
| Crash detection | Pattern matching in collector → publish to mc.crashes |
| Discord alerts | TinyMQ native webhook → Discord Incoming Webhook |
| Log persistence | WAL ensures logs survive TinyMQ restarts |
| Multi-server fan-in | All collectors publish to the same topic |
| No bot required | Discord webhook URL is just an HTTP endpoint |
| Observability | /dashboard shows message counts and consumer health |
Extending the Pattern
This architecture extends naturally:
- Player join/leave tracking: Parse
[INFO] PlayerName joined the game→ publish tomc.players - Performance monitoring: Parse TPS warnings → publish to
mc.performancewith TTL of 1h - Backup triggers: Publish to
mc.maintenancewhen[INFO] Saving worldis detected → trigger an S3 backup worker - Multi-game support: Swap Minecraft for Valheim, Palworld, or any server that writes logs to stdout