Skip to main content

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 stdout inside 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 Webhook Format

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

CapabilityImplementation
Unified log streamAll servers publish to mc.logs
Crash detectionPattern matching in collector → publish to mc.crashes
Discord alertsTinyMQ native webhook → Discord Incoming Webhook
Log persistenceWAL ensures logs survive TinyMQ restarts
Multi-server fan-inAll collectors publish to the same topic
No bot requiredDiscord 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 to mc.players
  • Performance monitoring: Parse TPS warnings → publish to mc.performance with TTL of 1h
  • Backup triggers: Publish to mc.maintenance when [INFO] Saving world is detected → trigger an S3 backup worker
  • Multi-game support: Swap Minecraft for Valheim, Palworld, or any server that writes logs to stdout