Why '2 Hours of Maintenance' Becomes 3 Days: The Engineering Behind Failed Deployments and Rollbacks
What turns a short maintenance window into days? A visual guide to failed deployments, database migrations and rollback, with Employment Permits Online as a case study.
As I write this on 27 October 2026, Ireland’s Employment Permits Online portal has shown this page for about three days:
“Site Unavailable. This site is unavailable due to essential maintenance. We apologise for any inconvenience caused and hope to resume normal service as soon as possible.”
An earlier notice on the portal announced a three-hour maintenance window (Tuesday 6 October, 6pm to 9pm). That’s what planned maintenance usually looks like. So what can turn hours into days? We don’t know the reason in this case, so this post doesn’t guess. Instead it walks through one common what if: a failed deployment that can’t be cleanly rolled back, especially when a database migration is involved.
The portal in brief: a cloud-based system for Irish work permit applications, live since 28 April 2025, when it replaced the older EPOS system. It uses email verification and multi-factor authentication (MFA), and appears to be hosted on Microsoft Azure App Service.
A normal maintenance window
A well-run window follows a script. About two hours of work, plus an hour of buffer for small surprises.
Figure 1 — A normal maintenance window: announce, freeze, holding page, backup, deploy, migrate, test, reopen.Deep dive: what each step is for
Announce, freeze: users plan around the date; no other changes go in, so if something breaks there’s one suspect.
Holding page: the load balancer or gateway (the front door all requests pass through) shows a static page, so no new data arrives mid-change.
Backup and snapshot: taken after traffic stops, so it’s complete and consistent.
Deploy, then migrations: a schema migration changes the database’s structure (add a column); a data migration changes its contents (convert every phone number).
Smoke tests: quick checks of the journeys people actually use: log in, get an MFA code, submit, pay.
Reopen and monitor: watch error rates and support channels for the next few hours.
Where it breaks
The buffer is sized for small surprises, not for a step that fails outright. These are the usual culprits:
Figure 2 — Where it breaks. Amber "!" means slow or degraded; red "✕" means blocked.
One of these adds hours. Two or three together add days.
Deep dive: the failure points in detail
Config drift: production quietly stops matching test. A secret rotated by hand, an expired TLS certificate (the file that lets browsers trust the site), a firewall rule that only exists in one place. On Azure App Service, settings can be marked as slot settings, which stay with an environment during a swap; mark the wrong one and the new version points at the wrong database.
Slow migration: see the next section.
Data corruption: migrations touch every row, so they find years-old problems: duplicates, impossible dates, broken links between tables.
Failed smoke test / broken integration: identity and MFA, SMS gateways, payments, email and registry lookups are each run by someone else, with their own credentials, rate limits and IP allow-lists.
Security finding: a scan during the window finds something that must be fixed before reopening.
Organisational overhead: in a large organisation every new fix is a new change, with approval, vendor tickets and sign-off.
Why migrations get slow at production scale
A migration that ran in seconds on test data can take most of an hour on the real thing, and that’s before anything goes wrong.
Figure 3 — Same script, 100× the rows, 100× the time. Then four hidden costs on top.
The fourth cost, locks, is the one that surprises even experienced teams. A schema change can freeze the whole site before it has started:
Figure 4 — The lock queue. The ALTER waits for the report; every new query waits for the ALTER.Deep dive: rewrites, indexes, logs and locks (with SQL)
Rewrites. Some changes only update the database’s catalogue (its record of what tables look like). In PostgreSQL 11+, adding a column with a constant default is one of those and finishes almost at once. A volatile default like now(), or changing a column’s type, rewrites the whole table.
Indexes. A plain CREATE INDEX blocks writes while it reads and sorts the table. CREATE INDEX CONCURRENTLY doesn’t, but it’s slower, can’t run inside a transaction, and if it fails it leaves an invalid index to drop and rebuild.
Logs. Every change is written to the transaction log first (the WAL in PostgreSQL). Millions of updated rows mean gigabytes of log, full disks and replicas (read-only copies) falling behind.
Locks. Most ALTER TABLE statements need an ACCESS EXCLUSIVE lock: nobody can even read the table. Waiting locks form a queue, so everything behind the ALTER waits too. The fix is to give up fast and retry:
-- PostgreSQL. If the lock isn't free within 3 seconds, cancel: the queue drains-- and the migration tool retries a little later.SETlock_timeout='3s';-- PG 11+: no default, so no table rewrite. Holds the lock only for a moment.ALTERTABLEapplicantADDCOLUMNmobile_e164varchar(16);-- Doesn't block writes, but must run outside a transaction block.-- If it fails, drop the INVALID index it leaves behind and run it again.CREATEINDEXCONCURRENTLYidx_applicant_mobileONapplicant(mobile_e164);
Can you cancel half-way? In PostgreSQL, cancelling a transaction is near-instant; VACUUM cleans up later. In SQL Server without Accelerated Database Recovery (ADR), undoing can take about as long as the work already done. Azure SQL Database always has ADR on, so rollback of a cancelled transaction is near-instant. On every engine, batches that already committed stay committed.
Rolling back: code is easy, data is hard
Code has no memory: redeploy yesterday’s version and it behaves like yesterday. A database remembers everything written since the change.
Figure 5 — Code vs. data rollback. RPO is how much data you can afford to lose; RTO is how long you can afford to be down.
A real example. In January 2017, GitLab accidentally deleted about 300 GB from its production database. Its regular backups turned out not to work. It restored from a copy an engineer had happened to take six hours earlier, and copying it back took around 18 hours on slow disks. About six hours of data were lost. GitLab published a very open postmortem, and its lesson is simple: a backup you haven’t restored isn’t a backup.
Deep dive: what an irreversible migration looks like
A realistic change: store phone numbers in one international format, so SMS codes send reliably.
-- One release, one step. Simple to write, impossible to undo safely.ALTERTABLEapplicantADDCOLUMNmobile_e164varchar(16);-- Rewrites every row in one transaction: slow on millions of rows.-- normalise_phone() stands in for your own conversion function.UPDATEapplicantSETmobile_e164=normalise_phone(phone);-- The point of no return. The old app version still reads "phone" and breaks at once,-- and a "down" migration can't rebuild the original values from the converted ones.ALTERTABLEapplicantDROPCOLUMNphone;
If the new SMS integration then fails, you can’t redeploy the old version: its column is gone. Your only routes are fix forward or restore.
Restore or repair?
Restoring a backup over production also deletes the good work done since. Experienced teams often prefer to repair just the damaged rows.
Figure 6 — Full restore vs. surgical repair. A repair keeps every good write, at the cost of more care.Deep dive: point-in-time restore and a surgical repair in SQL
Most production databases combine full, differential and transaction-log backups. Replaying the log lets you restore to an exact moment, say one minute before the migration: a point-in-time restore (PITR). Three costs bite during an outage:
Restore time grows with database size and log volume. Your real RTO is whatever that takes; rehearse it.
It often creates a new database. Azure SQL Database restores a point in time into a new database (history kept 1–35 days, 7 by default), so every connection string must be repointed mid-incident.
A full restore throws away everything written since that moment.
A surgical repair restores the backup alongside production and copies back only what was damaged. Using the irreversible example above:
-- PostgreSQL. restored_applicant was copied in from a point-in-time restore-- taken just before the migration ran.ALTERTABLEapplicantADDCOLUMNphonevarchar(32);-- bring the old column backUPDATEapplicantASliveSETphone=r.phoneFROMrestored_applicantASrWHEREr.id=live.id;-- Applicants created after the migration have no "before" value: check by hand.SELECTidFROMapplicantWHEREphoneISNULLANDmobile_e164ISNOTNULL;
What a restore can’t undo
Emails already sent, cards already charged, records already passed on. The fix is to record every side effect in the database first.
Figure 7 — The outbox pattern and idempotency keys make side effects traceable and retry-safe.Deep dive: outbox, idempotency keys and reconciliation SQL
Outbox. Save the business change and the intention to send in one transaction; a worker does the sending.
BEGIN;UPDATEapplicationSETstatus='submitted'WHEREid=42;INSERTINTOoutbox(event_id,kind,payload)VALUES(gen_random_uuid(),'submission_email','{"application_id": 42}');COMMIT;-- both rows are saved, or neither: no email for a submission that didn't happen
Idempotency keys. A timeout doesn’t tell you whether a payment went through. Send a unique key with each payment request; providers such as Stripe return the original result for a repeated key (within 24 hours) instead of charging again.
Reconcile the provider’s records against your restored database:
-- provider_payments: the payment provider's export for the incident window.-- Charged at the provider, but missing from our database: refund or re-create.SELECTp.provider_payment_id,p.amount,p.created_atFROMprovider_paymentsASpLEFTJOINpaymentASdONd.provider_payment_id=p.provider_payment_idWHEREd.provider_payment_idISNULL;
Sessions and caches are the subtle case: if the new version saved session data in a new format, the old one may not read it. Put the release version in cache keys and force a fresh login after a rollback.
Roll back or fix forward?
This one decision often sets whether an outage lasts hours or days. The key question is the second one: can the old code still run against the new database?
Figure 8 — Rollback decision tree. Green "✓" low risk, amber "!" medium risk, red "✕" data loss possible.
How a short window can become days (hypothetical)
No single step takes three days. The time comes from waiting, on a slow migration, a vendor, a decision or an approval, and each wait is reasonable on its own. This timeline is an illustrative example, not based on any real organisation.
Figure 9 — A hypothetical three-day outage across four swimlanes. Illustrative only.
How teams keep maintenance short
The goal isn’t zero failures. It’s keeping each failure small and “undo” cheap. Two patterns do most of the work.
Blue-green deployment: run two environments and switch traffic between them. Rollback is flipping the switch back. (On Azure App Service, deployment slots provide this built in. I don’t know how the permits portal itself is deployed.)
Figure 10 — Big-bang vs. blue-green. Both environments share one database, so the schema must suit both versions.
Expand/contract migrations: because blue and green share a database, split each risky schema change into steps the old code can live with.
Figure 11 — Expand/contract. Until the last release, rollback is just a code rollback.Deep dive: expand/contract in SQL
-- Release 1: EXPAND. Add the new column, nullable, so old code is unaffected.SETlock_timeout='3s';ALTERTABLEapplicantADDCOLUMNmobile_e164varchar(16);-- The app now writes BOTH columns on every save.-- Backfill old rows in small id ranges, outside peak hours. Repeat with the next range.-- "AND mobile_e164 IS NULL" makes it safe to stop and re-run.UPDATEapplicantSETmobile_e164=normalise_phone(phone)WHEREidBETWEEN1AND5000ANDmobile_e164ISNULL;-- Release 2: MIGRATE. The app READS mobile_e164 and still writes both.-- Before moving on, prove the new column is complete and correct. Must return 0.SELECTcount(*)FROMapplicantWHEREmobile_e164ISDISTINCTFROMnormalise_phone(phone);-- Release 3: CONTRACT. Weeks later, once nothing reads "phone",-- after a fresh, tested backup:ALTERTABLEapplicantDROPCOLUMNphone;
Four more habits round it out:
Feature flags: switch a new code path off without deploying.
Rehearse the undo: run the down migration and a real restore on production-sized data, and time it. That’s your honest RTO.
Watch user journeys, not servers: can someone log in, get an MFA code, submit and pay?
Regular status updates: what’s affected, what still works, and when the next update is due.
Deep dive: a feature flag and a deep health check (Python)
importzlib# Percentage of users who get each new feature. Set it to 0 to "roll back" with no deployment.
FLAGS={"new_mfa_provider":5}defenabled(flag:str,user_id:str)->bool:# A stable hash, so each user stays in the same group on every request.
bucket=zlib.crc32(f"{flag}:{user_id}".encode())%100returnbucket<FLAGS.get(flag,0)
A health check that tests each dependency a user journey needs:
importtimeimportpsycopgimportrequestsfromflaskimportFlask,jsonifyapp=Flask(__name__)DB_URL="postgresql://health_ro@db/permits"# a read-only health-check user
SMS_STATUS="https://sms-provider.example/status"# your providers' real status endpoints
PAYMENTS_STATUS="https://payments.example/health"defdatabase():withpsycopg.connect(DB_URL,connect_timeout=2)asconn:conn.execute("SELECT 1")defsms_mfa():requests.get(SMS_STATUS,timeout=2).raise_for_status()defpayments():requests.get(PAYMENTS_STATUS,timeout=2).raise_for_status()defrun(check):start=time.monotonic()try:check()status="ok"exceptExceptionase:# report the failure type, not the details: this endpoint may be public
status=f"fail ({type(e).__name__})"return{"status":status,"ms":round((time.monotonic()-start)*1000)}@app.get("/health/deep")defdeep_health():checks={"database":run(database),"sms_mfa":run(sms_mfa),"payments":run(payments)}healthy=all(c["status"]=="ok"forcinchecks.values())returnjsonify(status="ok"ifhealthyelse"degraded",checks=checks),200ifhealthyelse503
Don’t point the load balancer’s liveness check (the probe that decides whether a server gets traffic) at this endpoint. A one-minute SMS blip should raise an alert, not pull every server out of service.
Why it matters for public services
Behind every maintenance page are people with start dates, renewals and appointments, and an engineering team usually working flat out to bring the service back safely. Downtime can’t always be avoided. What engineering can do is keep each change small, keep the undo cheap, and make an honest estimate easy to give.
I’ve spent about ten years building ETL pipelines (jobs that move and reshape data between systems) and moving production databases, including migrating SQL Server to Azure SQL. The hardest part was never the code. It was the data.
27 October 2026: First published. The portal had been showing its “essential maintenance” page for about three days. The reason has not been shared publicly.
Frequently asked questions
Why is rolling back a database harder than rolling back code?
Code has no memory: you redeploy the old version and it behaves as before. A database keeps everything written since the change. Undoing it usually means restoring a backup, which loses the writes made after it, so someone has to reconcile them.
What are RPO and RTO?
RPO (recovery point objective) is how much data you can afford to lose, measured as time since the last good backup. RTO (recovery time objective) is how long you can afford to be down while you restore.
Why can one schema change freeze a whole website?
Most ALTER TABLE statements need an exclusive lock. If a long query is reading the table, the ALTER waits, and every new query queues behind it. Setting lock_timeout makes the ALTER give up after a few seconds so it can retry later.
What is an expand/contract migration?
A way to change a database schema in small, reversible steps across several releases: add the new structure, move the application and data over while both work, and only remove the old structure once nothing uses it.