petitviolet blog

    Maintenance Mode on Remix w/Google Cloud Armor

    2025-07-15

    RemixGCPNode.js

    tl;dr

    Use Google Cloud Armor's header_action to inject a custom header into requests from non-allowlisted IPs, so that your application can check this header in Remix's root loader and display a maintenance page if present.

    Background

    Sometimes you need to put your service into maintenance mode, but you want to allow access for certain IPs (e.g., dev, test, or monitoring).
    While you could toggle a flag in your application, using Google Cloud Armor allows you to control this at the infrastructure level, with minimal changes to your app. Or, even you could use DB to manage this, but you need DB operation to start maintenance mode, and you need to check DB status in your application every time request comes in that is not good in terms of performance.

    Implementation with Terraform

    Cloud Armor Security Policy

    We can use google_compute_security_policy to create a security policy that manages the maintenance mode related settings.

    variable "maintenance_mode" {
      type = object({
        is_maintenance    = bool
        available_ip_list = list(string)
      })
      default = {
        is_maintenance    = false
        available_ip_list = []
      }
    }
    
    resource "google_compute_security_policy" "policy" {
      name = "policy"
    
      rule {
        action      = "allow"
        priority    = "2147483647"
        description = "default rule"
        match {
          versioned_expr = "SRC_IPS_V1"
          config {
            src_ip_ranges = ["*"]
          }
        }
      }
    
      rule {
        action      = "allow"
        priority    = "0"
        description = "static file"
        match {
          expr {
            expression = <<-EOT
              request.path == "/health" # for health check
              || request.path == "/maintenance" # for maintenance page
              || request.path.startsWith("/assets/") # for Remix's asset serving
            EOT
          }
        }
      }
    
      dynamic "rule" {
        for_each = var.maintenance_mode.is_maintenance ? [1] : []
        content {
          action      = "allow"
          priority    = "1000"
          description = "maintenance redirect"
          match {
            expr {
              expression = <<-EOT
              !(${join(" || ", formatlist("inIpRange(origin.ip, '%s/32')", var.maintenance_mode.available_ip_list))})
            EOT
            }
          }
          header_action {
            request_headers_to_adds {
              header_name  = "X-Maintenance-Mode"
              header_value = "on"
            }
          }
        }
      }
    }
    

    This configuration allows all requests to pass through, except for the ones that match some specific routes.

    As Remix serves Web pages using server-side rendering (SSR) for the first request, and then uses client-side rendering (CSR) for subsequent requests, redirecting requests to the maintenance page will not work. Simply redirecting works as expected for the initial request, but for subsequent CSR requests, navigation is executed on the client side and the loader execution does not work properly due to the redirect, causing unintended errors on the client side.

    Caution: In case available_ip_list has more than 5 IPs, you should create dynamic block to create multiple rules since Cloud Armor's rule can have up to 5 expressions.

    Attach the Policy to the Backend Service

    Attach the security policy to your backend service.

    resource "google_compute_backend_service" "backend_service" {
      // ... (omitted) ...
      security_policy = google_compute_security_policy.app_policy.name
    }
    

    Key Points

    • header_action only works with allow actions, not redirect.
      • redirect action does not support header_action.
    • Static files and health checks are always allowed.
    • Non-allowlisted IPs will receive the X-Maintenance-Mode: on header.

    How to Handle Maintenance Mode in Remix

    On the application side, you can check for the X-Maintenance-Mode header in your Remix loader and handle maintenance mode accordingly.
    Below is an excerpt from the rootLoader implementation:

    export const rootLoader = async ({ request }: LoaderFunctionArgs) => {
      // If maintenance mode is enabled, Cloud Armor will set this header
      const isMaintenance = request.headers.get('X-Maintenance-Mode') === 'on'
      const status = isMaintenance ? 503 : 200
    
      return json({ isMaintenance }, { status })
    }
    
    // ...return response with status...
    

    As I described above, you need to check if the X-Maintenance-Mode header is present in the request at the root loader to determine if the page should be rendered in maintenance mode.

    Display Maintenance Page

    Use useRouteLoaderData to get the data from the root loader in the root layout.

    export const Layout = () => {
      const data = useRouteLoaderData<typeof rootLoader>('root')
    
      return (
        <html lang='en'>
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            {data?.isMaintenance ? <MaintenancePage /> : <Outlet />}
            <Scripts />
            <ScrollRestoration />
          </body>
        </html>
      )
    }
    

    Summary

    By leveraging Google Cloud Armor's header_action, you can implement a flexible maintenance mode with almost no changes to your application code.
    On the application side, simply check for the custom header and handle maintenance mode as needed.