Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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: EUR

homelab 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);

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);
}

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));
    }
}

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)

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)
    };
}

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");
}

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 FakeClock to 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.


⬇ Download