Guide · AWS Systems Manager · Scheduled Maintenance
AWS SSM Maintenance Windows for MCP Servers: Scheduled Restarts and Patching
SSM Maintenance Windows give you a declarative, auditable way to schedule recurring maintenance tasks on your MCP server fleet — restarts to clear memory leaks, dependency updates, log rotation, and OS patching — confined to a controlled time window with automatic cutoff. Without scheduled maintenance, MCP servers accumulate state over days: leaked file handles, growing in-memory caches, stale upstream connections. The server keeps responding to probes until it doesn't. Maintenance Windows let you schedule a weekly restart at 3 AM Sunday before a leak becomes an outage. Combined with AliveMCP monitoring, you confirm the server comes back within the window's duration — and alert if it doesn't. For the tasks that run inside a Maintenance Window, see SSM Run Command for MCP server fleet operations. For OS-level patching specifically, see SSM Patch Manager.
TL;DR
Create a Maintenance Window with a cron or rate schedule, a duration in hours, and a cutoff in hours (tasks stop being dispatched before the window closes). Register targets by tag. Register a Run Command task with your restart/update document. Attach an IAM service role that allows SSM to call ssm:SendCommand on your behalf. Tasks execute within the window; if duration expires, running tasks complete but no new ones start. AliveMCP probes during the window confirm recovery within the expected timeframe.
Core Maintenance Window concepts
A Maintenance Window has four components: a schedule, a duration, a cutoff, and a set of tasks registered against targets.
- Schedule — a cron expression or rate expression controlling when the window opens. Uses SSM's cron syntax:
cron(Minutes Hours Day-of-month Month Day-of-week Year). All times are UTC. - Duration — how long the window stays open, in hours (1–24). Tasks dispatched before the window closes can continue past the end of the window.
- Cutoff — how many hours before the end of the window to stop dispatching new tasks. Set this to 1 hour for a 4-hour window to ensure no task starts in the last hour, giving in-progress tasks time to complete.
- Tasks — Run Command tasks (most common), Automation tasks, Lambda tasks, or Step Functions tasks.
The window itself has no awareness of whether the tasks succeeded. That's intentional — the window is a scheduling construct, not an orchestration engine. You check task execution history in Run Command or Lambda logs separately.
Creating a Maintenance Window in CDK
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as iam from 'aws-cdk-lib/aws-iam';
// Weekly maintenance window: Sunday 03:00 UTC, 2 hours, cutoff 1 hour before end
const maintenanceWindow = new ssm.CfnMaintenanceWindow(this, 'McpMaintenanceWindow', {
name: 'mcp-server-weekly-maintenance',
description: 'Weekly MCP server restart and dependency refresh',
schedule: 'cron(0 3 ? * SUN *)', // 03:00 UTC every Sunday
duration: 2, // window open for 2 hours
cutoff: 1, // stop dispatching new tasks 1 hour before window closes
allowUnassociatedTargets: false,
enabled: true,
scheduleTimezone: 'UTC',
});
// Register targets: all instances tagged Service=mcp-server, Env=production
const windowTargets = new ssm.CfnMaintenanceWindowTarget(this, 'McpWindowTargets', {
windowId: maintenanceWindow.ref,
resourceType: 'INSTANCE',
targets: [
{ key: 'tag:Service', values: ['mcp-server'] },
{ key: 'tag:Env', values: ['production'] },
],
name: 'mcp-production-fleet',
});
// IAM service role: SSM needs permission to call SendCommand on your behalf
const maintenanceRole = new iam.Role(this, 'MaintenanceWindowRole', {
assumedBy: new iam.ServicePrincipal('ssm.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonSSMMaintenanceWindowRole'),
],
});
// Register a Run Command task: weekly restart
const restartTask = new ssm.CfnMaintenanceWindowTask(this, 'McpRestartTask', {
windowId: maintenanceWindow.ref,
taskArn: 'AWS-RunShellScript',
taskType: 'RUN_COMMAND',
targets: [{ key: 'WindowTargetIds', values: [windowTargets.ref] }],
priority: 1,
maxConcurrency: '25%',
maxErrors: '20%',
serviceRoleArn: maintenanceRole.roleArn,
taskInvocationParameters: {
maintenanceWindowRunCommandParameters: {
documentVersion: '$DEFAULT',
parameters: {
commands: [
'systemctl restart mcp-server',
'sleep 10',
'systemctl is-active mcp-server && echo "RESTART_OK" || echo "RESTART_FAILED"',
],
},
cloudWatchOutputConfig: {
cloudWatchLogGroupName: '/mcp/maintenance',
cloudWatchOutputEnabled: true,
},
timeoutSeconds: 300,
},
},
});
The sleep 10 after restart gives the server time to initialize before the health check. The final systemctl is-active check writes a parseable token (RESTART_OK or RESTART_FAILED) to the CloudWatch log — useful for a Lambda that scans maintenance window completion logs and alerts if any instance failed to recover.
Task types for MCP server maintenance
| Task | Type | MaxConcurrency | Notes |
|---|---|---|---|
| Weekly server restart | Run Command — AWS-RunShellScript |
25% | Stagger restarts so load balancer always has healthy instances; AliveMCP confirms recovery |
| npm dependency update | Run Command — custom mcp-update-deps document |
10% | Test in staging window first; update document verifies checksum before replacing binaries |
| Log rotation and upload to S3 | Run Command — AWS-RunShellScript |
100% | Read-only from production perspective; safe to run on all instances simultaneously |
| OS patching | Run Command — AWS-RunPatchBaseline |
10% | Integrated with Patch Manager; use Operation=Install |
| Post-maintenance health check | Lambda — invoke after Run Command completes | 1 | Lambda calls AliveMCP API or directly probes instances; alerts if recovery rate < 95% |
| Config cache bust | Run Command — HTTP POST to admin endpoint | 50% | Force all instances to re-fetch config from Parameter Store or AppConfig |
Choosing the right window schedule for MCP servers
MCP servers typically see traffic patterns correlated with developer working hours — traffic drops sharply at night and on weekends. The right maintenance window picks the lowest-traffic period in your usage data.
Common schedules for developer-tool MCP servers:
# Every Sunday at 03:00 UTC (lowest traffic for US-heavy traffic)
cron(0 3 ? * SUN *)
# Every Saturday at 18:00 UTC (Friday night US, Saturday morning AUS — avoids both)
cron(0 18 ? * SAT *)
# First Sunday of each month at 03:00 UTC (for longer patching windows)
cron(0 3 ? * SUN#1 *)
# Every night at 04:00 UTC (for servers with true 24/7 traffic — minimal window)
cron(0 4 * * ? *)
For multi-region MCP server deployments, stagger windows across regions by 2–4 hours so maintenance tasks never run simultaneously in all regions. The us-east-1 window at 03:00 UTC and the eu-west-1 window at 05:00 UTC provide overlapping global coverage with no simultaneous maintenance across regions.
Check your AliveMCP analytics to see actual traffic patterns before committing to a window schedule. The probe log timestamps in /data/sites-agent-12.log correlate with traffic peaks — look for multi-hour quiet periods with no client connections.
Cutoff behavior and task completion
The cutoff is one of the most misunderstood Maintenance Window settings. It controls when SSM stops dispatching new task invocations, not when running invocations are killed. If your window duration is 4 hours and the cutoff is 1 hour, tasks dispatched in the first 3 hours continue to run until they complete (or hit their own TimeoutSeconds). Tasks that have not yet been dispatched when the cutoff fires are cancelled.
For MCP server restarts, this distinction matters: if you have 100 instances and 25% concurrency, SSM dispatches to batches of 25 at a time. With a 2-hour window and 1-hour cutoff, you have 1 hour to complete all batches. For a restart that takes 2 minutes per batch of 25 instances, 100 instances (4 batches) needs 8 minutes — well within the window. For a full OS patch that takes 20 minutes per instance, 100 instances with 10% concurrency (10 at a time) needs at least 200 minutes — you need a longer window.
Estimate window duration before creating the window:
const instanceCount = 100;
const concurrencyPercent = 0.10; // 10%
const batchSize = Math.ceil(instanceCount * concurrencyPercent); // 10
const minutesPerBatch = 20; // estimated task duration
const batches = Math.ceil(instanceCount / batchSize); // 10 batches
const totalMinutes = batches * minutesPerBatch; // 200 minutes
const durationHours = Math.ceil(totalMinutes / 60) + 1; // 5 hours (includes buffer)
// Set cutoff = 1 hour before end → set duration = 5, cutoff = 1
AliveMCP during the maintenance window
AliveMCP continues probing every 60 seconds during maintenance windows. This is intentional — the probe validates that the maintenance operation recovered the server within the expected time, not just that the maintenance script completed.
The expected pattern during a rolling restart with 25% concurrency: AliveMCP sees a batch of instances go offline (probe failure), recover (probe success), then the next batch goes offline, then recovers. The aggregate probe success rate for the fleet dips but never reaches zero because 75% of instances are always running while the current batch restarts. If the rate reaches zero — all instances offline simultaneously — that is an unexpected state worth alerting on.
Configure your AliveMCP alert thresholds to allow for the expected dip during the maintenance window. A threshold of "alert if 2 consecutive probes fail on the same instance" (the default 2-minute alert delay) is appropriate: a 2-minute restart is expected; a 4-minute outage on a single instance suggests the restart failed and warrants investigation.
After the Maintenance Window closes, check that all instances in the AliveMCP dashboard have returned to green before considering the maintenance complete. A single instance still in the failed state after the window closes means the restart failed on that instance — use Session Manager to investigate without opening SSH.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Window shows as scheduled but tasks never execute | No registered targets match (tag mismatch or instances not SSM-managed); window schedule is in the past; maintenance window is disabled | Check "Targets" tab in the window — verify it shows at least one instance; confirm instances appear in Systems Manager Fleet Manager; check window's Enabled flag |
| Tasks dispatched to 0 instances despite correct tags | AllowUnassociatedTargets is false and target registration uses a tag that resolves to zero instances at execution time |
Check that all production instances have the tag at the time the window opens; tags applied after launch propagate immediately to Systems Manager |
| Task execution cut off mid-fleet with remaining instances never receiving the task | Cutoff fired before all batches were dispatched; earlier batches took longer than expected, reducing time for remaining instances | Increase window duration; reduce MaxConcurrency to allow more time per batch; or split into two consecutive windows |
AccessDeniedException in task execution logs |
Maintenance Window service role is missing permissions; the role must have ssm:SendCommand scoped to the document and target instances |
Attach service-role/AmazonSSMMaintenanceWindowRole to the service role; verify the role trust policy allows ssm.amazonaws.com to assume it |
| MCP server instances remain offline after window closes | Restart command failed (dependency missing, binary corrupt, config parse error); or MaxErrors was reached and remaining instances were skipped |
Check Run Command invocation details in CloudWatch; use Session Manager to inspect the failed instances; fix the root cause; re-run manually outside the window |