All articles
12 September 2026
7 min read

An end-of-life calendar for the whole stack

By Tim Rutte, Cloud & Software ArchitectTopicLegacy & Modernization

A shelf of identical white tins, one of them dented and capped in blue.

No end-of-life date in a software stack arrives as a surprise. They are all fixed years in advance, published, with a date on them. They still get missed reliably, and not by inattentive teams but by good ones.

The reason is not ignorance. It is that a known date without an owner and without an entry in the plan is exactly as effective as an unknown one. Everybody knew the database version ran out in June. Nobody did anything about it before June.

This article describes a list that fixes that. It costs a day to build and half a day per quarter, and it is the cheapest way to turn a series of emergencies into a series of appointments.

What carries an expiry date

The first surprise when putting the list together is its length. People think of the language and the framework, and that is about a third of it.

Also carrying an expiry date: the operating system and every base image, the database including the version your managed service still offers, the runtime of your serverless functions, every client package for a service you do not operate, the API version of the payment provider, the signing method in the exchange with the accountant, the security protocols a browser still accepts, and the support contract for the box in the basement that keeps the time.

Two categories are nearly always forgotten, and they are the unpleasant ones.

Versions of managed services. With a database you run yourself you decide when to upgrade. With a managed one the provider decides, and they do it with an announcement and then without asking. A version reaching end of support gets raised in a maintenance window somebody else picked.

Third-party APIs. When the payment provider switches off a version, that is not a security topic, it is a revenue topic, and the notice period is shorter than for anything else on the list.

The list in a day

Most of it can be queried rather than collected. Three sources cover the bulk.

# 1. What is in the project, and what of it is behind.
composer outdated --direct --format=json > dependencies.json
go list -m -u -json all | jq -r 'select(.Update) | .Path'

# 2. The dates for those. endoflife.date knows most versions
#    and serves them machine-readable.
for p in php symfony ubuntu postgresql nodejs; do
  curl -s "https://endoflife.date/api/$p.json" \
    | jq -r --arg p "$p" '.[] | "\($p) \(.cycle) \(.eol)"'
done

# 3. And the dates your provider sets for you. This line finds
#    what would never have become a date on a database you run
#    yourself.
aws rds describe-db-engine-versions --engine postgres \
  --query 'DBEngineVersions[].{v:EngineVersion,end:SupportEndDate}'

What is still missing after that is the things that appear in no package registry: contracts, certificates, partner interfaces. Those come out of one meeting with two people and half an hour, and they are the part that only hurts once.

Where the list of packages comes from when nobody knows what is installed any more is covered in Composer as archaeology.

What a row has to contain

The list is worthless if it consists of a component and a date. Then it is a worry list, and worry lists do not get worked through.

Four more columns make it usable, and one of them is the decisive one.

Effort, roughly in days. Without that number nobody can plan, and the estimate does not have to be good: the difference between two days and three weeks is the information at issue.

Lead time, also in days, and that is something other than the effort. A PHP jump takes two weeks of work and three months of lead time, because libraries have to follow and a run in production sits in between.

The date that counts. Not the end-of-life date but end of life minus lead time. It is the only number in the list you sort by, and it is why the list works: it moves attention from "when does this run out" to "when do we have to start".

A name. Not a team, a name. A row without a name is a row that gets read out in the quarterly meeting and changes nothing.

Hard and soft dates

One distinction saves more discussion than any other, and it rarely appears on such lists.

A hard date means somebody else switches something off. The managed database gets raised. The API stops answering. The certificate is no longer accepted. These dates are not a trade-off, they are a day on which something stops working.

A soft date means it keeps running but stops receiving security updates. PHP 8.1 still works today. It just gets no more fixes, and that is a risk, not an outage.

The mistake that regularly follows: both get treated the same. Soft dates then move into the next quarter until they pile up, and hard dates get the same attention as a tidy-up project.

The rule that follows is short: hard dates are not negotiable and go into quarterly planning like a customer appointment. Soft ones get collected and done in groups, usually together with the next hard date in the same area, because then the testing work only happens once.

The part that happens on its own

A list maintained by hand is wrong after two quarters. So the reconciliation belongs in a job that runs once a week and only speaks up when something changes:

#!/usr/bin/env bash
# Runs on Mondays. Reports only what is inside its lead time:
# an alert that arrives every week stops being read after three.
today=$(date +%s)

while IFS=, read -r component version eol lead owner; do
  end=$(date -d "$eol" +%s)
  start=$(( end - lead * 86400 ))
  [ "$today" -lt "$start" ] && continue

  days=$(( (end - today) / 86400 ))
  echo "$component $version: $days days left, owner $owner"
done < eol-calendar.csv

Two decisions in there matter more than the code. Only what is inside its lead time gets reported, because a weekly full report goes unread after three weeks. And it gets reported with a name, because an alert to everybody is an alert to nobody.

How such a date actually gets worked through is shown for Symfony in Working through Symfony deprecations without drowning in the log.

When the date has already passed

The more common case when first building the list is not a date eight months out, it is one that was two years ago. There are three routes for that, and the first gets checked too rarely.

Buy extended support. It exists for the major operating systems and some databases, and it costs a fraction of what a rushed jump costs. It is not a solution, it is bought time, and that is exactly why it is right: it turns an emergency back into an appointment.

Fence the place in. If an old version only runs for part of the system, separate that part out: its own process, no direct access from outside, a narrow crossing to the rest. That does not lower the risk in the component, it lowers the surface the risk acts on.

Steps instead of a jump. PHP 7.2 to 8.4 in one move is an open-ended project. In three steps, each shippable on its own, it is a series of weeks. The intermediate state is never the goal and always better than the starting point.

What does not help in this situation is deciding to rebuild everything instead. That is the answer that shows up when a missed date feels like a fundamental problem, and it trades a known risk for an unknown one.

For systems where the jump no longer pays there is a different state: Maintenance mode.

The sentence that gets the budget

Which leaves the question of how this list gets funded, and that is where its real value sits.

An upgrade is hard to justify to a management team because it delivers nothing new. A date is easy to justify because it is not up for debate. The difference between these two sentences is the whole trick:

"We should move to PHP 8.4 at some point" is an opinion. "On 31 December our PHP version stops receiving security fixes; the move takes two weeks of work and three months of lead time, so we start at the end of September" is an appointment. The first sentence competes with features, the second does not.

That is also why this list pays off even when nobody looks at it for a quarter: it turns the technical debt that is hard to talk about into rows with dates that are easy to talk about. How a PHP upgrade runs in practice is described on its own page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.