Skip to content

Instance Maintenance Guide

How an instance handles the maintenance poster (pre-announcement banner) and maintenance mode (traffic blocking).


Two Distinct Concepts

ConceptWhen setInstance statusTenant trafficPurpose
Maintenance posterAny time — before or during maintenanceAnyUnaffectedInform users of upcoming/ongoing maintenance
Maintenance modeWhen maintenance beginsmaintenanceBlocked (503)Prevent access during active maintenance

A poster can be set while the instance is active to give users advance notice. Setting a poster does not block traffic. Enabling maintenance mode does not automatically create a poster — the two are independent but designed to work together.


How the Instance Receives This Data

Both the startup and heartbeat responses include:

json
{
  "status": "active",
  "maintenancePoster": {
    "message": "Scheduled maintenance for database migration.",
    "scheduledStart": "2026-03-15T02:00:00Z",
    "scheduledEnd": "2026-03-15T04:00:00Z",
    "setBy": "admin-user-id",
    "setAt": "2026-03-10T12:00:00Z"
  }
}

maintenancePoster is null (omitted) when:

  • No poster has been set, or
  • The current time is before scheduledStart, or
  • The current time is after scheduledEnd.

This means the Console handles the time-windowing — the instance only receives the poster when it is relevant to display.

Scheduling rules

scheduledStartscheduledEndPoster included in response when
not setnot setAlways (general announcement with no schedule)
setnot setFrom scheduledStart onward
not setsetUntil scheduledEnd
setsetOnly within [scheduledStart, scheduledEnd] window

What the Instance Should Do

1. Cache the state from every heartbeat / startup response

On receiving any heartbeat or startup response, the instance should atomically update two cached values:

go
type ConsoleState struct {
    Status            string
    MaintenancePoster *MaintenancePoster // nil when not in window
}

var consoleState atomic.Value // stores ConsoleState

Update on every heartbeat response:

go
consoleState.Store(ConsoleState{
    Status:            resp.Status,
    MaintenancePoster: resp.MaintenancePoster,
})

2. Availability middleware

All tenant-facing requests should pass through an availability middleware that reads the cached state:

go
func AvailabilityMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        state := consoleState.Load().(ConsoleState)
        switch state.Status {
        case "maintenance", "decommissioned":
            renderUnavailable(w, state)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Internal Console endpoints (/internal/*) must bypass this middleware — heartbeats must keep flowing so the instance can learn when maintenance is lifted.

3. Maintenance page (status = "maintenance")

When status is maintenance, return an HTML maintenance page (not a JSON error):

With poster:

html
<!DOCTYPE html>
<html>
  <head>
    <title>Scheduled Maintenance</title>
    <meta http-equiv="refresh" content="60" />
  </head>
  <body>
    <h1>We're under maintenance</h1>
    <p>{{ poster.Message }}</p>
    {% if poster.ScheduledEnd %}
    <p>
      Expected to be back by
      <strong>{{ poster.ScheduledEnd | humanTime }}</strong>
    </p>
    {% endif %}
    <p>
      Check <a href="https://status.exto360.com">status.exto360.com</a> for live
      updates.
    </p>
    <p>
      Taking longer than expected?
      <a href="https://support.exto360.com">Open a support ticket</a>.
    </p>
  </body>
</html>

Without poster (fallback):

html
<h1>We're temporarily unavailable</h1>
<p>We're performing maintenance. We'll be back shortly.</p>
<p>
  Check <a href="https://status.exto360.com">status.exto360.com</a> for live
  updates.
</p>

The <meta http-equiv="refresh" content="60"> tag causes the browser to reload every 60 seconds. When maintenance is lifted, the next heartbeat returns status: "active" and subsequent page loads go through normally.

4. Decommissioned page (status = "decommissioned")

Decommissioned instances show a different message — users should not expect the service to return:

html
<h1>This service has been retired</h1>
<p>Please contact support to arrange access to a new environment.</p>
<p><a href="https://support.exto360.com">Open a support ticket</a></p>

Do not auto-refresh on decommissioned — the instance will not come back.

5. In-app banner (status = "active" with poster present)

When status is active but maintenancePoster is set, show a dismissible banner in the app shell. The poster appears in the heartbeat response only when now is within the scheduled window, so no client-side time comparison is needed.

Example banner (React):

tsx
function MaintenanceBanner({ poster }: { poster: MaintenancePoster }) {
  return (
    <div className="maintenance-banner">
      <span>{poster.message}</span>
      {poster.scheduledStart && (
        <span> Starts {formatTime(poster.scheduledStart)}.</span>
      )}
      {poster.scheduledEnd && (
        <span> Expected end: {formatTime(poster.scheduledEnd)}.</span>
      )}
    </div>
  );
}

Dismiss state should be stored in sessionStorage keyed on poster.setAt so re-dismissal is not required after every heartbeat.


Full Status → Behaviour Reference

statusmaintenancePosterTenant trafficUI behaviour
activenullAllowedNormal
activesetAllowedShow dismissible banner in app shell
degradednullAllowedNormal (Console auto-set; instance continues serving)
degradedsetAllowedShow dismissible banner
maintenancenullBlocked (503)Generic maintenance page with status/support links
maintenancesetBlocked (503)Maintenance page with poster message + schedule
decommissionedanyBlocked permanentlyDecommission page — no auto-refresh

Recovering from Maintenance

The instance learns maintenance is over on the next heartbeat — no restart required.

Admin calls DELETE /api/v1/instances/:id/maintenance
  → Console sets status: maintenance → active
  → Next heartbeat response: { "status": "active" }
  → Instance middleware updates cached state
  → Browser auto-refresh (60s) reloads into the live app

Median user impact: up to 60 seconds (one heartbeat interval + one browser refresh cycle).