Wer Bitcoin-Solo-Mining mit Hardware wie dem NerdMiner, Bitaxe oder ausgemusterten GPU/ASIC-Rigs betreiben möchte, benötigt kein externes Mining-Netzwerk. Mit der Open-Source-Software public-pool und einer eigenen Bitcoin Full Node (bitcoind) lässt sich eine komplett unabhängige, non-custodial Solo-Mining-Infrastruktur aufbauen: Findet ein Miner einen gültigen Block, geht die Block-Belohnung (Coinbase Transaction) ohne Umwege und ohne Pool-Gebühren direkt an die eigene Wallet.
In der Praxis bringt das vorgefertigte Standard-Setup oft Nachteile mit sich: Das mitgelieferte Web-Frontend (public-pool-ui) belegt unnötig Arbeitsspeicher, lässt sich schwer in bestehende Server-Dashboards integrieren und führt bei externen API-Abfragen schnell zu CORS-Problemen. In diesem Leitfaden bauen wir den vollständigen Stack von Grund auf: von der Bitcoin-Node über das Mining-Backend bis hin zum maßgeschneiderten, performanten PHP-Dashboard.
1. Die Architektur im Überblick
Das Gesamtsystem besteht aus drei aufeinander abgestimmten Komponenten, die über ein internes Docker-Bridge-Netzwerk miteinander kommunizieren:
- Bitcoin Core Full Node (
bitcoind): Validiert die Blockchain, verwaltet den Mempool und liefert Blockvorlagen über RPC (Port8332) sowie ZMQ-Block-Events (Port28332). - public-pool Backend (NestJS): Verwaltet die Stratum-Verbindungen der Miner (Port
21496), konstruiert Coinbase-Transaktionen und stellt eine REST-API bereit (Port3334). - Custom Apache/PHP Dashboard: Ersetzt das ressourcenhungrige Standard-Frontend (
public-pool-ui). Es dient als gehärteter API-Proxy, speichert historische Hashrate-Daten und visualisiert Worker via Chart.js im Browser.
2. Speicherbedarf & Sync-Dauer (WICHTIG!)
Bevor wir den Stack starten, zwei elementare Praxishinweise zur Bitcoin-Node, die oft zu Verwirrung führen:
- Speicherplatz (Pruning): Eine vollständige Blockchain (ohne Pruning) belegt gut und gerne 650 GB bis 700 GB Speicherplatz. Das ist für reines Solo-Mining nicht zwingend notwendig! Es hat sich bewährt, die Node mit dem Parameter
-prune=5000zu starten. Dadurch verwirft die Node alte Blockdaten und der Speicherbedarf des bitcoind-Containers schrumpft auf sehr verträgliche ca. 17 GB. - Initial Block Download (IBD) & Verbindungsfehler: Die Node muss beim ersten Start die gesamte Historie seit 2009 herunterladen. Dieser Sync dauert auf einem performanten Server mit reiner SSD-Ausstattung gut und gerne 24 Stunden oder länger.
Achtung: Solange dieser Sync nicht zu 100 % durchgelaufen ist, verweigertbitcoinddie Bereitstellung von Block-Templates. Das bedeutet: Daspublic-poolAPI-Backend bekommt keinen Connect zur Node und wirft in den Logs Fehler oder wartet in einer Endlosschleife. Erst wenn der Node-Sync restlos abgeschlossen ist, verbindet sich der public-pool erfolgreich und man sieht, dass das Setup funktioniert!
3. Die vollständige docker-compose.yml
Erstelle auf deinem Host-System ein zentrales Projektverzeichnis (z. B. /opt/mining-stack) mit folgender Verzeichnisstruktur:
/opt/mining-stack/
├── docker-compose.yml
├── bitcoin-data/
├── html/
│ └── index.php
└── data/
Lege im Hauptverzeichnis die docker-compose.yml an. Sie definiert die Bitcoin Node, das public-pool Backend und den Apache-Webserver in einem gemeinsamen Netzwerk:
services:
# 1. BITCOIN FULL NODE (bitcoind)
bitcoind:
image: lncm/bitcoind:v27.0
container_name: bitcoind-node
restart: unless-stopped
volumes:
- ./bitcoin-data:/root/.bitcoin
ports:
- "8333:8333" # P2P Netzwerk
command:
- -server=1
- -txindex=0
- -prune=5000 # Beschränkt den Speicher auf ca. 17 GB
- -rpcbind=0.0.0.0
- -rpcallowip=0.0.0.0/0
- -rpcuser=pooluser
- -rpcpassword=StrengGeheimesPasswort123!
- -zmqpubrawblock=tcp://0.0.0.0:28332
networks:
- mining-net
# 2. PUBLIC-POOL STRATUM & API BACKEND
public-pool:
image: benedikteth/public-pool:latest
container_name: public-pool-backend
restart: unless-stopped
depends_on:
- bitcoind
environment:
- BITCOIN_RPC_URL=http://bitcoind:8332
- BITCOIN_RPC_USER=pooluser
- BITCOIN_RPC_PASSWORD=StrengGeheimesPasswort123!
- BITCOIN_RPC_TIMEOUT=10000
- BITCOIN_ZMQ_HOST=tcp://bitcoind:28332
- API_PORT=3334
- STRATUM_PORT=21496
- NETWORK=mainnet
ports:
- "21496:21496" # Stratum V1 Port für Miner
- "3334:3334" # Interne REST-API
networks:
- mining-net
# 3. EIGENES PHP-DASHBOARD (Ersetzt public-pool-ui)
dashboard-web:
image: php:8.3-apache
container_name: mining-dashboard
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./html:/var/www/html
- ./data:/var/www/html/posts
environment:
- TZ=Europe/Berlin
networks:
- mining-net
networks:
mining-net:
driver: bridge
4. Das Dashboard: index.php erstellen
Erstelle im Ordner html die Datei index.php. Wichtig ist hierbei die globale Zeitzonen-Kompensation (date_default_timezone_set('Europe/Berlin')), da Docker-Container standardmäßig in UTC laufen. Ohne diese Anpassung würden im Graphen andernfalls falsche (um 2 Stunden versetzte) Sommerzeit-Timestamps landen.
<?php
session_start();
date_default_timezone_set('Europe/Berlin');
// =========================================================================
// API PROXY & AUTOMATISCHER HISTORY-LOGGER
// =========================================================================
if (isset($_GET['api_action'])) {
header('Content-Type: application/json');
$api_base = "http://public-pool:3334/api/";
$action = $_GET['api_action'];
$endpoint = '';
if ($action === 'history') {
$file = __DIR__ . '/posts/hashrate_history.json';
echo file_exists($file) ? file_get_contents($file) : '[]';
exit;
}
if (in_array($action, ['pool', 'network', 'info'])) {
$endpoint = $action;
} elseif (preg_match('/^client\/[a-zA-Z0-9]+$/', $action)) {
$endpoint = $action;
} else {
echo '{}';
exit;
}
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
$data = @file_get_contents($api_base . $endpoint, false, $ctx);
if ($action === 'pool' && $data) {
$json = json_decode($data, true);
$hr = $json['totalHashRate'] ?? 0;
$file = __DIR__ . '/posts/hashrate_history.json';
$history = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
$last_time = end($history)['timestamp'] ?? 0;
$now = time();
if ($now - $last_time >= 300) {
$history[] = [
'timestamp' => $now,
'label' => date('d.m. H:i'),
'hashrate' => $hr
];
if (count($history) > 432) array_shift($history);
file_put_contents($file, json_encode($history), LOCK_EX);
}
}
echo $data ?: '{}';
exit;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bitcoin Solo Pool Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { background: #141210; color: #f3ede7; font-family: sans-serif; padding: 20px; }
.card { background: #1c1815; border: 1px solid #382e27; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
input { background: #141210; border: 1px solid #382e27; color: #fff; padding: 10px; border-radius: 4px; }
button { background: #e05a2b; color: #fff; border: none; padding: 10px 18px; border-radius: 4px; cursor: pointer; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; }
th, td { padding: 10px; border-bottom: 1px solid #382e27; text-align: left; }
</style>
</head>
<body>
<h1>⛏️ Bitcoin Solo Mining Dashboard</h1>
<div class="card">
<h2>📊 Live Pool Hashrate (36 Stunden Verlauf)</h2>
<div style="height: 250px;"><canvas id="hashChart"></canvas></div>
</div>
<div class="card">
<h2>🔍 Eigene Worker prüfen</h2>
<input type="text" id="wallet-in" placeholder="Bitcoin-Wallet (bc1q...) eingeben" style="width: 320px;">
<button onclick="fetchWorkers()">Suchen</button>
<table>
<thead>
<tr><th>Worker</th><th>Sessions</th><th>Hashrate</th><th>Best Difficulty</th></tr>
</thead>
<tbody id="worker-rows">
<tr><td colspan="4" style="color: #a89f91;">Bitte Wallet-Adresse suchen.</td></tr>
</tbody>
</table>
</div>
<script>
const ctx = document.getElementById('hashChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [{ label: 'Hashrate (H/s)', data: [], borderColor: '#e05a2b', fill: true, backgroundColor: 'rgba(224,90,43,0.1)' }] },
options: { responsive: true, maintainAspectRatio: false }
});
async function loadHistory() {
const res = await fetch('index.php?api_action=history').then(r => r.json());
if (Array.isArray(res) && res.length > 0) {
chart.data.labels = res.map(p => p.label);
chart.data.datasets[0].data = res.map(p => p.hashrate);
chart.update();
}
}
async function fetchWorkers() {
const wallet = document.getElementById('wallet-in').value.trim();
if (!wallet) return;
const res = await fetch('index.php?api_action=client/' + encodeURIComponent(wallet)).then(r => r.json());
const list = res.workers || [];
let html = '';
if (list.length === 0) {
html = '<tr><td colspan="4">Keine aktiven Worker gefunden.</td></tr>';
} else {
list.forEach(w => {
html += `<tr><td><b>${w.name}</b></td><td>${(w.sessions||[]).length}</td><td>${(w.hashrate||0).toLocaleString()} H/s</td><td>${w.bestDifficulty||0}</td></tr>`;
});
}
document.getElementById('worker-rows').innerHTML = html;
}
loadHistory();
setInterval(loadHistory, 300000);
</script>
</body>
</html>
5. Deployment & Berechtigungen
Setze vor dem ersten Start die Berechtigungen für das Datenverzeichnis, damit der Webserver-Prozess (www-data / UID 33) die Verlaufsdatei schreiben kann:
chmod -R 775 ./data && chown -R 33:33 ./data
Starte den kompletten Stack im Hintergrund:
docker compose up -d
6. 24/7 Graphen-Aufzeichnung sicherstellen
Da PHP zustandslos arbeitet, wird der 5-Minuten-History-Check standardmäßig nur ausgeführt, wenn jemand die Seite aufruft. Um einen lückenlosen Graphen zu garantieren, richten wir einen automatischen Trigger ein:
- Via Uptime Kuma / Monitoring: Lege einen HTTP(s)-Monitor auf die URL
http://<DEINE-IP>:8080/index.php?api_action=poolmit einem Prüfintervall von 300 Sekunden an. - Via Host-Cronjob (Alternative): Trage per
crontab -eauf deinem Server folgende Zeile ein:*/5 * * * * curl -s "http://127.0.0.1:8080/?api_action=pool" > /dev/null
7. Wichtige Praxistipps & Troubleshooting
- Leeres Dashboard nach dem Start? Keine Panik! Selbst wenn die Full Node fertig synchronisiert ist, liefert die API des Pools (und damit auch unsere PHP-Seite) erst dann Daten und Hashrates, wenn sich der allererste Worker erfolgreich verbunden und Hash-Shares gesendet hat. Vorher bleiben der Graph und die Kacheln auf null.
- Miner mit dem Pool verbinden: Trage in der Weboberfläche deines Miners (z. B. Bitaxe) folgende Stratum-Parameter ein:
- Stratum URL:
stratum+tcp://<DEINE-SERVER-IP>:21496 - User / Worker-Name:
<DeineBitcoinAdresse>.<WorkerName> - Passwort:
x(oder leer lassen)
- Stratum URL:
- Warum weichen Hashrates ab? Das Backend berechnet die globale Pool-Hashrate über ein langes Zeitfenster (z. B. 15–30 Minuten), während Worker-Hashrates auf kurzfristigen Share-Intervallen (1–5 Minuten) basieren. Abweichungen sind statistisch bedingt völlig normal.
💬 Feedback & Diskussion 0
Kommentar hinterlassen
Noch keine Kommentare zu diesem Guide vorhanden. Sei der Erste!