Part 47: Cost Tracking — Yes, a Homelab Has a Cost
"Electricity is real. Attention is real. Opportunity is real. The cost is non-zero, and pretending otherwise is how a 'free' homelab quietly takes over a Saturday."
Why
A homelab is "free" the way a hobby is "free": no invoice, but a real cost in time, electricity, and the alternatives you did not pursue. Most developers ignore this because there is no clean way to surface it. There is no AWS bill at the end of the month. There is no Datadog alert. The cost accumulates silently until the moment you wonder why you have not had a free Saturday in three months.
The thesis of this part is: HomeLab tracks the cost of every lab, every VM, and every compose stack — wall-clock running time, CPU-hours, RAM-GB-hours, disk-GB-hours — and surfaces it via homelab cost report. The numbers are honest, deterministic, and (unlike cloud bills) computable from local data. The point is not to bill the user; the point is to make the cost visible.
What we measure
Five units, all derived from things HomeLab already knows:
| Metric | Source | Why it matters |
|---|---|---|
| Wall-clock VM hours | IClock ticks between VosUp and VosHalt events |
Energy proxy |
| CPU-hours | vcpus × wall-clock hours |
Compute proxy |
| RAM-GB-hours | memory_gb × wall-clock hours |
Capacity proxy |
| Disk-GB-hours | volume_gb × wall-clock hours |
Storage proxy |
| Build minutes | IClock between PackerBuildStarted and PackerBuildCompleted |
Cost of new images |
These are not real costs. They are proxies for real costs. To convert them into euros (or watts or CO₂), the user provides a multiplier in the config:
cost:
enabled: true
power_watts_per_cpu_hour: 5 # rough estimate for an idle modern x86 core
power_watts_per_gb_ram_hour: 0.5 # rough estimate for DDR4
power_price_per_kwh: 0.20 # local electricity price
currency: EURcost:
enabled: true
power_watts_per_cpu_hour: 5 # rough estimate for an idle modern x86 core
power_watts_per_gb_ram_hour: 0.5 # rough estimate for DDR4
power_price_per_kwh: 0.20 # local electricity price
currency: EURhomelab cost report then prints a number in EUR. If the user does not provide multipliers, the report shows raw resource units, which are still useful — they let you compare runs and see when a particular lab is consuming more than expected.
The shape
public sealed record CostMeasurement(
string LabName,
string MachineName,
DateTimeOffset From,
DateTimeOffset To,
int Cpus,
int MemoryMb,
int DiskGb)
{
public double WallClockHours => (To - From).TotalHours;
public double CpuHours => Cpus * WallClockHours;
public double RamGbHours => (MemoryMb / 1024.0) * WallClockHours;
public double DiskGbHours => DiskGb * WallClockHours;
}
public sealed record CostReport(
string LabName,
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<CostMeasurement> PerMachine,
double TotalWallClockHours,
double TotalCpuHours,
double TotalRamGbHours,
double TotalDiskGbHours,
double? EstimatedKwh,
double? EstimatedCost,
string? Currency);public sealed record CostMeasurement(
string LabName,
string MachineName,
DateTimeOffset From,
DateTimeOffset To,
int Cpus,
int MemoryMb,
int DiskGb)
{
public double WallClockHours => (To - From).TotalHours;
public double CpuHours => Cpus * WallClockHours;
public double RamGbHours => (MemoryMb / 1024.0) * WallClockHours;
public double DiskGbHours => DiskGb * WallClockHours;
}
public sealed record CostReport(
string LabName,
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<CostMeasurement> PerMachine,
double TotalWallClockHours,
double TotalCpuHours,
double TotalRamGbHours,
double TotalDiskGbHours,
double? EstimatedKwh,
double? EstimatedCost,
string? Currency);The measurements are immutable records. The report aggregates them. The estimated cost is a function of the measurements and the user's multipliers.
The collector
Cost data is collected from the event bus:
[Injectable(ServiceLifetime.Singleton)]
public sealed class CostMeasurementCollector : IHomeLabEventSubscriber
{
private readonly ICostMeasurementStore _store;
private readonly IClock _clock;
private readonly ConcurrentDictionary<string, RunningMachine> _running = new();
public CostMeasurementCollector(ICostMeasurementStore store, IClock clock)
{
_store = store;
_clock = clock;
}
public void Subscribe(IHomeLabEventBus bus)
{
bus.Subscribe<VosUpCompleted>(OnUp);
bus.Subscribe<VosHaltCompleted>(OnHalt);
bus.Subscribe<VosDestroyCompleted>(OnDestroy);
}
private Task OnUp(VosUpCompleted e, CancellationToken ct)
{
// Look up the VM's spec from the topology resolver
var spec = _topology.GetMachine(e.MachineName);
_running[e.MachineName] = new RunningMachine
{
Lab = spec.LabName,
Name = e.MachineName,
Cpus = spec.Cpus,
MemoryMb = spec.Memory,
DiskGb = spec.DiskGb,
Started = _clock.UtcNow
};
return Task.CompletedTask;
}
private async Task OnHalt(VosHaltCompleted e, CancellationToken ct)
{
if (!_running.TryRemove(e.MachineName, out var running)) return;
var measurement = new CostMeasurement(
LabName: running.Lab,
MachineName: running.Name,
From: running.Started,
To: _clock.UtcNow,
Cpus: running.Cpus,
MemoryMb: running.MemoryMb,
DiskGb: running.DiskGb);
await _store.AppendAsync(measurement, ct);
}
private Task OnDestroy(VosDestroyCompleted e, CancellationToken ct) => OnHalt(new VosHaltCompleted(e.MachineName, e.Timestamp), default);
}[Injectable(ServiceLifetime.Singleton)]
public sealed class CostMeasurementCollector : IHomeLabEventSubscriber
{
private readonly ICostMeasurementStore _store;
private readonly IClock _clock;
private readonly ConcurrentDictionary<string, RunningMachine> _running = new();
public CostMeasurementCollector(ICostMeasurementStore store, IClock clock)
{
_store = store;
_clock = clock;
}
public void Subscribe(IHomeLabEventBus bus)
{
bus.Subscribe<VosUpCompleted>(OnUp);
bus.Subscribe<VosHaltCompleted>(OnHalt);
bus.Subscribe<VosDestroyCompleted>(OnDestroy);
}
private Task OnUp(VosUpCompleted e, CancellationToken ct)
{
// Look up the VM's spec from the topology resolver
var spec = _topology.GetMachine(e.MachineName);
_running[e.MachineName] = new RunningMachine
{
Lab = spec.LabName,
Name = e.MachineName,
Cpus = spec.Cpus,
MemoryMb = spec.Memory,
DiskGb = spec.DiskGb,
Started = _clock.UtcNow
};
return Task.CompletedTask;
}
private async Task OnHalt(VosHaltCompleted e, CancellationToken ct)
{
if (!_running.TryRemove(e.MachineName, out var running)) return;
var measurement = new CostMeasurement(
LabName: running.Lab,
MachineName: running.Name,
From: running.Started,
To: _clock.UtcNow,
Cpus: running.Cpus,
MemoryMb: running.MemoryMb,
DiskGb: running.DiskGb);
await _store.AppendAsync(measurement, ct);
}
private Task OnDestroy(VosDestroyCompleted e, CancellationToken ct) => OnHalt(new VosHaltCompleted(e.MachineName, e.Timestamp), default);
}The collector subscribes to VosUpCompleted and VosHaltCompleted. When a machine starts, it records the start time and the spec. When the machine halts, it produces a CostMeasurement and appends it to the store. The store is a small SQLite or JSONL file at .homelab/cost.db.
The use of IClock is important: in tests, FakeClock lets us simulate a year of operation in milliseconds and assert on the resulting cost calculation.
The report
[Injectable(ServiceLifetime.Singleton)]
public sealed class CostReportRequestHandler : IRequestHandler<CostReportRequest, Result<CostReport>>
{
private readonly ICostMeasurementStore _store;
private readonly IOptions<HomeLabConfig> _config;
private readonly IClock _clock;
public async Task<Result<CostReport>> HandleAsync(CostReportRequest req, CancellationToken ct)
{
var measurements = await _store.QueryAsync(req.LabName, req.From, req.To, ct);
if (measurements.IsFailure) return measurements.Map<CostReport>();
var totalWall = measurements.Value.Sum(m => m.WallClockHours);
var totalCpu = measurements.Value.Sum(m => m.CpuHours);
var totalRam = measurements.Value.Sum(m => m.RamGbHours);
var totalDisk = measurements.Value.Sum(m => m.DiskGbHours);
double? kwh = null, cost = null;
if (_config.Value.Cost?.Enabled == true)
{
var cfg = _config.Value.Cost;
var watts =
totalCpu * cfg.PowerWattsPerCpuHour +
totalRam * cfg.PowerWattsPerGbRamHour;
kwh = watts / 1000.0;
cost = kwh * cfg.PowerPricePerKwh;
}
return Result.Success(new CostReport(
LabName: req.LabName,
From: req.From,
To: req.To,
PerMachine: measurements.Value,
TotalWallClockHours: totalWall,
TotalCpuHours: totalCpu,
TotalRamGbHours: totalRam,
TotalDiskGbHours: totalDisk,
EstimatedKwh: kwh,
EstimatedCost: cost,
Currency: _config.Value.Cost?.Currency));
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class CostReportRequestHandler : IRequestHandler<CostReportRequest, Result<CostReport>>
{
private readonly ICostMeasurementStore _store;
private readonly IOptions<HomeLabConfig> _config;
private readonly IClock _clock;
public async Task<Result<CostReport>> HandleAsync(CostReportRequest req, CancellationToken ct)
{
var measurements = await _store.QueryAsync(req.LabName, req.From, req.To, ct);
if (measurements.IsFailure) return measurements.Map<CostReport>();
var totalWall = measurements.Value.Sum(m => m.WallClockHours);
var totalCpu = measurements.Value.Sum(m => m.CpuHours);
var totalRam = measurements.Value.Sum(m => m.RamGbHours);
var totalDisk = measurements.Value.Sum(m => m.DiskGbHours);
double? kwh = null, cost = null;
if (_config.Value.Cost?.Enabled == true)
{
var cfg = _config.Value.Cost;
var watts =
totalCpu * cfg.PowerWattsPerCpuHour +
totalRam * cfg.PowerWattsPerGbRamHour;
kwh = watts / 1000.0;
cost = kwh * cfg.PowerPricePerKwh;
}
return Result.Success(new CostReport(
LabName: req.LabName,
From: req.From,
To: req.To,
PerMachine: measurements.Value,
TotalWallClockHours: totalWall,
TotalCpuHours: totalCpu,
TotalRamGbHours: totalRam,
TotalDiskGbHours: totalDisk,
EstimatedKwh: kwh,
EstimatedCost: cost,
Currency: _config.Value.Cost?.Currency));
}
}homelab cost report --since 2026-01-01 walks the store, aggregates, and prints:
Cost report for devlab (2026-01-01 to 2026-04-11)
─────────────────────────────────────────────────────────────────
Total wall-clock VM hours: 1,742
Total CPU-hours: 6,968 (4 machines × 4 cpu × ~430h each)
Total RAM-GB-hours: 13,936
Total disk-GB-hours: 201,600
Power proxy: 41.8 kWh
Estimated electricity cost: €8.36
─────────────────────────────────────────────────────────────────
Per machine:
devlab-gateway 421h (842 cpu-h, 421 ram-GB-h, 9 GB)
devlab-platform 423h (1692 cpu-h, 3384 ram-GB-h, 50 GB)
devlab-data 449h (898 cpu-h, 1796 ram-GB-h, 100 GB)
devlab-obs 449h (898 cpu-h, 898 ram-GB-h, 20 GB)Cost report for devlab (2026-01-01 to 2026-04-11)
─────────────────────────────────────────────────────────────────
Total wall-clock VM hours: 1,742
Total CPU-hours: 6,968 (4 machines × 4 cpu × ~430h each)
Total RAM-GB-hours: 13,936
Total disk-GB-hours: 201,600
Power proxy: 41.8 kWh
Estimated electricity cost: €8.36
─────────────────────────────────────────────────────────────────
Per machine:
devlab-gateway 421h (842 cpu-h, 421 ram-GB-h, 9 GB)
devlab-platform 423h (1692 cpu-h, 3384 ram-GB-h, 50 GB)
devlab-data 449h (898 cpu-h, 1796 ram-GB-h, 100 GB)
devlab-obs 449h (898 cpu-h, 898 ram-GB-h, 20 GB)The numbers are honest. The "estimated electricity cost" is a proxy that ignores the actual workload (idle vs busy CPUs differ wildly), but it gives the user a sense of magnitude. Eight euros over three months is a real number that you can compare to "renting a small VPS for the same workload" (which would be about €30/month, or €90 total — nearly 11x more).
The [ResourceBudget] enforcement
Ops.Cost lets contributors declare resource budgets:
public sealed class GitLabComposeContributor : IComposeFileContributor
{
public IEnumerable<OpsCostDeclaration> Cost => new[]
{
new OpsResourceBudget(
Target: "gitlab",
CpuHoursPerMonth: 200, // soft cap
RamGbHoursPerMonth: 1500,
ActionOnExceed: BudgetAction.Warn)
};
}public sealed class GitLabComposeContributor : IComposeFileContributor
{
public IEnumerable<OpsCostDeclaration> Cost => new[]
{
new OpsResourceBudget(
Target: "gitlab",
CpuHoursPerMonth: 200, // soft cap
RamGbHoursPerMonth: 1500,
ActionOnExceed: BudgetAction.Warn)
};
}The cost report compares actual usage against budgets and warns when a target exceeds. The action can be Warn (logged), Alert (push to Alertmanager), or Halt (the most aggressive — stop the offending VM until the next month). Most teams use Warn for visibility and never need the others.
The test
[Fact]
public async Task collector_appends_measurement_on_vos_halt()
{
var clock = new FakeClock(DateTimeOffset.Parse("2026-04-11T00:00:00Z"));
var store = new InMemoryCostStore();
var topology = new FakeTopologyResolver(new VosMachineConfig
{
Name = "devlab-main",
LabName = "devlab",
Cpus = 4, Memory = 8192, DiskGb = 50
});
var collector = new CostMeasurementCollector(store, clock, topology);
var bus = new HomeLabEventBus(clock);
collector.Subscribe(bus);
await bus.PublishAsync(new VosUpCompleted("devlab-main", TimeSpan.FromSeconds(60), clock.UtcNow), default);
clock.Advance(TimeSpan.FromHours(2));
await bus.PublishAsync(new VosHaltCompleted("devlab-main", clock.UtcNow), default);
var measurements = await store.GetAllAsync(default);
measurements.Should().ContainSingle();
measurements[0].WallClockHours.Should().Be(2.0);
measurements[0].CpuHours.Should().Be(8.0);
measurements[0].RamGbHours.Should().Be(16.0);
}
[Fact]
public async Task report_with_cost_config_emits_estimated_kwh_and_eur()
{
var store = new InMemoryCostStore();
await store.AppendAsync(new CostMeasurement(
"devlab", "devlab-main",
DateTimeOffset.Parse("2026-04-01"), DateTimeOffset.Parse("2026-04-11"),
Cpus: 4, MemoryMb: 8192, DiskGb: 50), default);
var handler = new CostReportRequestHandler(store, Options.Create(new HomeLabConfig
{
Cost = new()
{
Enabled = true,
PowerWattsPerCpuHour = 5,
PowerWattsPerGbRamHour = 0.5,
PowerPricePerKwh = 0.20,
Currency = "EUR"
}
}), Mock.Of<IClock>());
var report = await handler.HandleAsync(new CostReportRequest("devlab", default, default), default);
report.IsSuccess.Should().BeTrue();
report.Value.EstimatedKwh.Should().BeGreaterThan(0);
report.Value.EstimatedCost.Should().BeGreaterThan(0);
report.Value.Currency.Should().Be("EUR");
}[Fact]
public async Task collector_appends_measurement_on_vos_halt()
{
var clock = new FakeClock(DateTimeOffset.Parse("2026-04-11T00:00:00Z"));
var store = new InMemoryCostStore();
var topology = new FakeTopologyResolver(new VosMachineConfig
{
Name = "devlab-main",
LabName = "devlab",
Cpus = 4, Memory = 8192, DiskGb = 50
});
var collector = new CostMeasurementCollector(store, clock, topology);
var bus = new HomeLabEventBus(clock);
collector.Subscribe(bus);
await bus.PublishAsync(new VosUpCompleted("devlab-main", TimeSpan.FromSeconds(60), clock.UtcNow), default);
clock.Advance(TimeSpan.FromHours(2));
await bus.PublishAsync(new VosHaltCompleted("devlab-main", clock.UtcNow), default);
var measurements = await store.GetAllAsync(default);
measurements.Should().ContainSingle();
measurements[0].WallClockHours.Should().Be(2.0);
measurements[0].CpuHours.Should().Be(8.0);
measurements[0].RamGbHours.Should().Be(16.0);
}
[Fact]
public async Task report_with_cost_config_emits_estimated_kwh_and_eur()
{
var store = new InMemoryCostStore();
await store.AppendAsync(new CostMeasurement(
"devlab", "devlab-main",
DateTimeOffset.Parse("2026-04-01"), DateTimeOffset.Parse("2026-04-11"),
Cpus: 4, MemoryMb: 8192, DiskGb: 50), default);
var handler = new CostReportRequestHandler(store, Options.Create(new HomeLabConfig
{
Cost = new()
{
Enabled = true,
PowerWattsPerCpuHour = 5,
PowerWattsPerGbRamHour = 0.5,
PowerPricePerKwh = 0.20,
Currency = "EUR"
}
}), Mock.Of<IClock>());
var report = await handler.HandleAsync(new CostReportRequest("devlab", default, default), default);
report.IsSuccess.Should().BeTrue();
report.Value.EstimatedKwh.Should().BeGreaterThan(0);
report.Value.EstimatedCost.Should().BeGreaterThan(0);
report.Value.Currency.Should().Be("EUR");
}What this gives you that bash doesn't
A bash script does not measure cost. A bash script does not have IClock. A bash script does not have an event bus to subscribe to. A bash script does not generate a quarterly report.
A typed cost-tracking subscriber gives you, for the same surface area:
- Five raw metrics (wall-clock, cpu-h, ram-h, disk-h, build minutes)
- A persistent store (SQLite or JSONL) of historical measurements
- A typed report with optional power and currency conversion
- Per-machine breakdowns
[ResourceBudget]enforcement with three escalation levels- Tests using
FakeClockto simulate years of operation in milliseconds
The bargain pays back the first time homelab cost report shows you that the obs VM is consuming as much CPU-h as gitlab — and you discover that Loki has been ingesting four GB of debug logs per day because someone left it on.