Maintenance Mode on Remix w/Google Cloud Armor
2025-07-15
RemixGCPNode.jstl;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 withallow
actions, notredirect
.redirect
action does not supportheader_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.