惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

AI
AI
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Google DeepMind News
Google DeepMind News
T
Tenable Blog
博客园_首页
S
Securelist
Spread Privacy
Spread Privacy
Google Online Security Blog
Google Online Security Blog
Forbes - Security
Forbes - Security
Engineering at Meta
Engineering at Meta
U
Unit 42
L
LINUX DO - 热门话题
量子位
T
Threat Research - Cisco Blogs
博客园 - 【当耐特】
C
Cyber Attacks, Cyber Crime and Cyber Security
K
Kaspersky official blog
MyScale Blog
MyScale Blog
P
Proofpoint News Feed
The Last Watchdog
The Last Watchdog
Google DeepMind News
Google DeepMind News
GbyAI
GbyAI
Martin Fowler
Martin Fowler
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Security Latest
Security Latest
Scott Helme
Scott Helme
V
Vulnerabilities – Threatpost
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
I
InfoQ
Know Your Adversary
Know Your Adversary
Cisco Talos Blog
Cisco Talos Blog
The Register - Security
The Register - Security
T
The Blog of Author Tim Ferriss
aimingoo的专栏
aimingoo的专栏
V2EX - 技术
V2EX - 技术
T
Tailwind CSS Blog
月光博客
月光博客
Recent Announcements
Recent Announcements
G
Google Developers Blog
F
Full Disclosure
W
WeLiveSecurity
宝玉的分享
宝玉的分享
腾讯CDC
G
GRAHAM CLULEY
Vercel News
Vercel News
Simon Willison's Weblog
Simon Willison's Weblog
美团技术团队
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Help Net Security
Help Net Security

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
How to Automatically Replicate AWS SSM Parameters Across Regions Using EventBridge and Lambda
Rishi Vachhani · 2026-06-21 · via DEV Community

AWS Systems Manager Parameter Store is regional by design.

If you create a parameter in ap-south-1, it doesn't automatically exist in us-east-1.

For teams running disaster recovery environments, active-passive architectures, or multi-region applications, maintaining identical configuration across regions quickly becomes an operational challenge.

In this guide, we'll build a fully serverless solution that automatically replicates SSM parameters whenever they are created, updated, or deleted.

The solution uses:

  • AWS Systems Manager Parameter Store
  • AWS CloudTrail
  • Amazon EventBridge
  • AWS Lambda
  • Python (boto3)

The end result is a low-cost, event-driven synchronization mechanism that keeps regions aligned automatically.


Why I Built This

While working on a disaster recovery setup recently, I noticed something that often gets overlooked.

Infrastructure was deployed in multiple regions.

Application containers were deployed.

Databases were replicated.

Monitoring was configured.

Everything looked good.

Until we started validating failover.

Several services immediately failed because critical SSM parameters only existed in the primary region.

Things like:

/prod/app/DB_PASSWORD
/prod/app/API_KEY
/prod/api/JWT_SECRET

were missing in the secondary region.

AWS Parameter Store is regional, which means your applications only see parameters that exist within the region they're running in.

The obvious solution is manually maintaining copies.

The practical solution is automating synchronization.


Architecture Overview

Whenever someone creates, updates, or deletes a parameter:

  1. CloudTrail records the API call.
  2. EventBridge captures the event.
  3. Lambda is invoked.
  4. Lambda determines the operation type.
  5. Lambda replicates the change to one or more destination regions.
Create / Update / Delete Parameter
                │
                ▼
           CloudTrail
                │
                ▼
           EventBridge
                │
                ▼
              Lambda
                │
                ▼
      Destination Region(s)

No servers.

No polling.

No scheduled jobs.

No manual intervention.


Why Replicate Deletes?

In many organizations, disaster recovery environments are expected to mirror production as closely as possible.

If a parameter is intentionally removed from the source region, keeping it in destination regions can introduce configuration drift.

By replicating delete operations as well:

  • Regions remain fully synchronized
  • Stale parameters are automatically removed
  • Configuration remains consistent across environments
  • Operational overhead is reduced

⚠️ Production Note:

If accidental deletions are a concern in your organization, consider removing delete replication and handling deletions manually through change management processes.


Prerequisites

Before getting started, ensure you have:

  • AWS CLI configured
  • Python 3.10+
  • boto3 installed
  • CloudTrail enabled
  • Lambda execution role with SSM permissions

Install boto3:

pip install boto3


Step 1 - Enable CloudTrail

EventBridge receives SSM API activity through CloudTrail.

Without CloudTrail, EventBridge never sees Parameter Store changes.

Navigate to:

AWS Console
→ CloudTrail
→ Trails
→ Create Trail

Recommended settings:

Management Events : Enabled
API Activity      : Write
CloudWatch Logs   : Enabled

Once enabled, CloudTrail begins recording Parameter Store API calls.


Step 2 - Create the EventBridge Rule

Navigate to:

AWS Console
→ EventBridge
→ Rules
→ Create Rule

Use the following event pattern:

{
  "source": ["aws.ssm"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["ssm.amazonaws.com"],
    "eventName": [
      "PutParameter",
      "DeleteParameter",
      "DeleteParameters"
    ]
  }
}

This ensures that parameter creations, updates, and deletions trigger the replication Lambda.


Step 3 - Deploy the Lambda Replicator

Environment Variables

Variable Description Example
DEST_REGIONS Destination AWS regions us-east-1,eu-west-1
PARAM_PATHS Parameter paths to replicate /prod/app,/prod/api
DEST_KMS_KEY_ID KMS key for SecureString parameters alias/prod-key
DRY_RUN Preview mode without writes false

Example Configuration

DEST_REGIONS=us-east-1,eu-west-1
PARAM_PATHS=/prod/app,/prod/api
DEST_KMS_KEY_ID=alias/prod-key
DRY_RUN=false


Lambda Function

The Lambda performs the following actions:

  1. Receives EventBridge events
  2. Extracts parameter names
  3. Determines create, update, or delete operations
  4. Validates watched paths
  5. Reads source parameter values
  6. Replicates values and tags
  7. Deletes parameters in destination regions when required
  8. Produces structured CloudWatch logs

Supported Features

  • PutParameter
  • DeleteParameter
  • DeleteParameters
  • Native Parameter Store Change Events
  • Multiple destination regions
  • SecureString replication
  • Tag replication
  • Dry-run mode
"""
SSM Parameter Store Cross-Region Replicator
============================================
Triggered by: EventBridge rule watching SSM Parameter Store events in source region
              (PutParameter, DeleteParameter)

Environment Variables (set in Lambda config):
  DEST_REGIONS      : Comma-separated destination regions  e.g. "us-east-1,eu-west-1"
  PARAM_PATHS       : Comma-separated path prefixes to watch e.g. "/prod/app,/prod/api"
  DEST_PARAM_TYPE   : (Optional) Force a type — String | SecureString | StringList
                      Default: preserves the source parameter's original type
  DEST_KMS_KEY_ID   : (Optional) KMS Key ID/ARN to use in destination for SecureString
                      Default: uses AWS managed key (alias/aws/ssm)
  DRY_RUN           : (Optional) Set to "true" to log actions without writing. Default: false

IAM permissions required on Lambda execution role:
  Source region:
    ssm:GetParameter
    ssm:GetParametersByPath
    ssm:DescribeParameters

  Destination region(s):
    ssm:PutParameter
    ssm:DeleteParameter
    ssm:AddTagsToResource

  KMS (if using SecureString with custom key):
    kms:GenerateDataKey
    kms:Decrypt
"""

import boto3
import json
import logging
import os
import re
from botocore.exceptions import ClientError, BotoCoreError

# ── Logging ──────────────────────────────────────────────────────────────────
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def log_info(msg, **kwargs):
    logger.info(json.dumps({"level": "INFO", "message": msg, **kwargs}))

def log_warn(msg, **kwargs):
    logger.warning(json.dumps({"level": "WARN", "message": msg, **kwargs}))

def log_error(msg, **kwargs):
    logger.error(json.dumps({"level": "ERROR", "message": msg, **kwargs}))


# ── Config ───────────────────────────────────────────────────────────────────

class Config:
    """Reads and validates all environment variables at cold-start."""

    def __init__(self):
        self.dest_regions   = self._parse_list("DEST_REGIONS")
        self.param_paths    = self._parse_list("PARAM_PATHS")
        self.dest_param_type = os.environ.get("DEST_PARAM_TYPE", "").strip() or None
        self.dest_kms_key   = os.environ.get("DEST_KMS_KEY_ID", "").strip() or None
        self.dry_run        = os.environ.get("DRY_RUN", "false").strip().lower() == "true"
        self._validate()

    def _parse_list(self, env_var):
        raw = os.environ.get(env_var, "").strip()
        if not raw:
            raise EnvironmentError(f"Required environment variable '{env_var}' is not set or empty.")
        return [item.strip() for item in raw.split(",") if item.strip()]

    def _validate(self):
        valid_regions_pattern = re.compile(r'^[a-z]{2}-[a-z]+-\d+$')
        for region in self.dest_regions:
            if not valid_regions_pattern.match(region):
                raise ValueError(f"Invalid region format: '{region}'. Expected format like 'us-east-1'.")

        for path in self.param_paths:
            if not path.startswith("/"):
                raise ValueError(f"PARAM_PATHS entry '{path}' must start with '/'. e.g. '/prod/app'")

        valid_types = {"String", "StringList", "SecureString"}
        if self.dest_param_type and self.dest_param_type not in valid_types:
            raise ValueError(f"DEST_PARAM_TYPE '{self.dest_param_type}' is invalid. Choose from: {valid_types}")

        if self.dry_run:
            log_warn("DRY_RUN is enabled — no parameters will be written.")

        log_info("Configuration loaded",
                 dest_regions=self.dest_regions,
                 param_paths=self.param_paths,
                 dest_param_type=self.dest_param_type or "preserve-source",
                 dest_kms_key=self.dest_kms_key or "aws-managed-default",
                 dry_run=self.dry_run)


# Load config once at cold-start so errors surface immediately in CloudWatch
try:
    CONFIG = Config()
except (EnvironmentError, ValueError) as e:
    log_error("Configuration error — Lambda will not process events until fixed.", error=str(e))
    CONFIG = None


# ── Helpers ──────────────────────────────────────────────────────────────────

def normalize_path(path: str) -> str:
    """Ensure path starts with / and has no trailing slash."""
    path = path.strip()
    if not path.startswith("/"):
        path = "/" + path
    return path.rstrip("/")


def path_is_watched(param_name: str, watched_paths: list) -> bool:
    """Return True if param_name is under one of the watched path prefixes."""
    for prefix in watched_paths:
        if param_name == prefix or param_name.startswith(prefix + "/"):
            return True
    return False


def get_source_parameter(ssm_source, param_name: str) -> dict | None:
    """
    Fetch the current value + metadata of a parameter from the source region.
    Returns None if the parameter does not exist (e.g. was deleted).
    """
    try:
        response = ssm_source.get_parameter(Name=param_name, WithDecryption=True)
        param = response["Parameter"]
        log_info("Fetched source parameter",
                 name=param_name,
                 type=param.get("Type"),
                 version=param.get("Version"))
        return param
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code == "ParameterNotFound":
            log_warn("Parameter not found in source region — may have been deleted.", name=param_name)
            return None
        log_error("Failed to fetch source parameter",
                  name=param_name,
                  error_code=code,
                  error=str(e))
        raise


def get_parameter_tags(ssm_source, param_name: str) -> list:
    """Fetch tags from the source parameter. Returns empty list on failure."""
    try:
        response = ssm_source.list_tags_for_resource(
            ResourceType="Parameter",
            ResourceId=param_name
        )
        return response.get("TagList", [])
    except ClientError as e:
        log_warn("Could not fetch tags for parameter — tags will not be replicated.",
                 name=param_name,
                 error=str(e))
        return []


def replicate_put(ssm_dest, dest_region: str, param: dict, tags: list, config: Config):
    """Write a parameter to the destination region."""
    name  = param["Name"]
    value = param["Value"]
    ptype = config.dest_param_type or param.get("Type", "SecureString")
    desc  = param.get("Description", "Replicated by SSM cross-region replicator Lambda")

    put_kwargs = dict(
        Name=name,
        Value=value,
        Type=ptype,
        Description=desc,
        Overwrite=True,
        Tier="Standard",
    )

    if ptype == "SecureString" and config.dest_kms_key:
        put_kwargs["KeyId"] = config.dest_kms_key

    log_info("Replicating parameter",
             action="PUT",
             name=name,
             dest_region=dest_region,
             type=ptype,
             dry_run=config.dry_run)

    if config.dry_run:
        return

    try:
        response = ssm_dest.put_parameter(**put_kwargs)
        log_info("Parameter replicated successfully",
                 name=name,
                 dest_region=dest_region,
                 version=response.get("Version"))

        # Replicate tags if any
        if tags:
            ssm_dest.add_tags_to_resource(
                ResourceType="Parameter",
                ResourceId=name,
                Tags=tags
            )
            log_info("Tags replicated", name=name, dest_region=dest_region, tag_count=len(tags))

    except ClientError as e:
        log_error("Failed to replicate parameter",
                  name=name,
                  dest_region=dest_region,
                  error_code=e.response["Error"]["Code"],
                  error=str(e))
        raise


def replicate_delete(ssm_dest, dest_region: str, param_name: str, config: Config):
    """Delete a parameter in the destination region."""
    log_info("Replicating delete",
             action="DELETE",
             name=param_name,
             dest_region=dest_region,
             dry_run=config.dry_run)

    if config.dry_run:
        return

    try:
        ssm_dest.delete_parameter(Name=param_name)
        log_info("Parameter deleted successfully",
                 name=param_name,
                 dest_region=dest_region)
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code == "ParameterNotFound":
            log_warn("Parameter already absent in destination region — nothing to delete.",
                     name=param_name,
                     dest_region=dest_region)
            return
        log_error("Failed to delete parameter",
                  name=param_name,
                  dest_region=dest_region,
                  error_code=code,
                  error=str(e))
        raise


def parse_event(event: dict) -> tuple[str, list, str]:
    """
    Extract (action, param_names, source_region) from the EventBridge event.

    param_names is always a list — most events carry a single parameter,
    but the console's "Delete" button (and any batch delete) calls the
    DeleteParameters (plural) API, which can carry multiple names in one
    CloudTrail event.

    Supported event sources:
      - EventBridge + CloudTrail: detail-type = "AWS API Call via CloudTrail"
      - EventBridge native SSM events: detail-type = "Parameter Store Change"
    """
    detail      = event.get("detail", {})
    detail_type = event.get("detail-type", "")
    source_region = event.get("region", os.environ.get("AWS_REGION", ""))

    # ── CloudTrail-based event ────────────────────────────────────────────────
    if detail_type == "AWS API Call via CloudTrail":
        event_name = detail.get("eventName", "")
        request_params = detail.get("requestParameters", {}) or {}

        action_map = {
            "PutParameter"     : "PUT",
            "DeleteParameter"  : "DELETE",
            "DeleteParameters" : "DELETE",
        }
        action = action_map.get(event_name)
        if not action:
            raise ValueError(f"Unsupported or ignored CloudTrail eventName: '{event_name}'")

        if event_name == "DeleteParameters":
            # Batch delete — names come back as a list
            raw_names = request_params.get("names") or request_params.get("Names") or []
            if not raw_names:
                raise ValueError(f"Could not extract parameter names from CloudTrail event: {json.dumps(detail)}")
            param_names = [normalize_path(n) for n in raw_names]
        else:
            # Single put/delete — name comes back as a string
            raw_name = request_params.get("name") or request_params.get("Name") or ""
            if not raw_name:
                raise ValueError(f"Could not extract parameter name from CloudTrail event: {json.dumps(detail)}")
            param_names = [normalize_path(raw_name)]

        return action, param_names, source_region

    # ── Native SSM EventBridge event ──────────────────────────────────────────
    if detail_type == "Parameter Store Change":
        operation = detail.get("operation", "")
        param_name = detail.get("name", "")

        if not param_name:
            raise ValueError(f"Could not extract parameter name from SSM event: {json.dumps(detail)}")

        action_map = {
            "Create" : "PUT",
            "Update" : "PUT",
            "Delete" : "DELETE",
            "LabelParameterVersion": None,  # Ignored
        }
        action = action_map.get(operation)
        if action is None:
            raise ValueError(f"Unsupported or ignored SSM operation: '{operation}'")

        return action, [normalize_path(param_name)], source_region

    raise ValueError(f"Unsupported event detail-type: '{detail_type}'. "
                     f"Expected 'AWS API Call via CloudTrail' or 'Parameter Store Change'.")


# ── Lambda Handler ────────────────────────────────────────────────────────────

def lambda_handler(event, context):
    log_info("Lambda invoked",
             request_id=context.aws_request_id,
             function=context.function_name,
             remaining_ms=context.get_remaining_time_in_millis())

    # Config failed at cold-start — abort early with a clear message
    if CONFIG is None:
        log_error("Aborting: Lambda has invalid configuration. Fix environment variables and redeploy.")
        return {"statusCode": 500, "body": "Invalid Lambda configuration — check CloudWatch logs."}

    # ── Parse the incoming event ──────────────────────────────────────────────
    try:
        action, param_names, source_region = parse_event(event)
    except ValueError as e:
        log_error("Event parsing failed — skipping.", error=str(e), raw_event=json.dumps(event))
        return {"statusCode": 400, "body": str(e)}

    log_info("Event parsed",
             action=action,
             param_names=param_names,
             source_region=source_region)

    # ── Filter down to only the parameters under a watched path ────────────────
    watched_param_names = [p for p in param_names if path_is_watched(p, CONFIG.param_paths)]
    skipped_param_names = [p for p in param_names if p not in watched_param_names]

    if skipped_param_names:
        log_info("Some parameters are not under a watched path — skipping them.",
                 skipped=skipped_param_names,
                 watched_paths=CONFIG.param_paths)

    if not watched_param_names:
        log_info("No parameters in this event are under a watched path — skipping.",
                 param_names=param_names,
                 watched_paths=CONFIG.param_paths)
        return {"statusCode": 200, "body": "No parameters in watched paths — skipped."}

    # ── Source SSM client ─────────────────────────────────────────────────────
    ssm_source = boto3.client("ssm", region_name=source_region)

    all_results = {}

    for param_name in watched_param_names:
        results = {}

        # ── Replicate to each destination region ──────────────────────────────
        for dest_region in CONFIG.dest_regions:

            if dest_region == source_region:
                log_warn("Destination region is the same as source — skipping to avoid overwrite.",
                         region=dest_region)
                results[dest_region] = "skipped-same-region"
                continue

            ssm_dest = boto3.client("ssm", region_name=dest_region)

            try:
                if action == "DELETE":
                    replicate_delete(ssm_dest, dest_region, param_name, CONFIG)
                    results[dest_region] = "deleted"
                    continue

                # Fetch latest value from source
                param = get_source_parameter(ssm_source, param_name)
                if param is None:
                    log_warn("Source parameter vanished before replication — skipping.",
                             name=param_name,
                             dest_region=dest_region)
                    results[dest_region] = "skipped-source-not-found"
                    continue

                tags = get_parameter_tags(ssm_source, param_name)
                replicate_put(ssm_dest, dest_region, param, tags, CONFIG)
                results[dest_region] = "replicated"

            except (ClientError, BotoCoreError) as e:
                log_error("Replication failed for destination region",
                          dest_region=dest_region,
                          param_name=param_name,
                          error=str(e))
                results[dest_region] = f"error: {str(e)}"

        all_results[param_name] = results

    log_info("Replication complete", action=action, results=all_results)

    return {
        "statusCode": 200,
        "body": json.dumps({
            "action": action,
            "results": all_results
        })
    }


IAM Permissions

The Lambda execution role requires the following permissions.

Source Region

{
  "Effect": "Allow",
  "Action": [
    "ssm:GetParameter",
    "ssm:GetParametersByPath",
    "ssm:DescribeParameters",
    "ssm:ListTagsForResource"
  ],
  "Resource": "*"
}

Destination Regions

{
  "Effect": "Allow",
  "Action": [
    "ssm:PutParameter",
    "ssm:DeleteParameter",
    "ssm:AddTagsToResource"
  ],
  "Resource": "*"
}

If SecureString parameters use a customer-managed KMS key:

{
  "Effect": "Allow",
  "Action": [
    "kms:Decrypt",
    "kms:GenerateDataKey"
  ],
  "Resource": "*"
}


Step 4 - Bulk Sync Existing Parameters

The Lambda only handles future changes.

What about parameters that already exist?

For that we use a one-time bulk synchronization script.

This script:

  • Reads all parameters under configured prefixes
  • Preserves parameter values
  • Preserves SecureString encryption
  • Preserves descriptions
  • Preserves tags
  • Writes everything to the destination region

Preview Before Writing

Always start with a dry run.

DRY_RUN=true \
SOURCE_REGION=ap-south-1 \
DESTINATION_REGION=us-east-1 \
PARAMETER_PREFIXES=/prod/app,/prod/api \
python bulk_sync_ssm.py

Example output:

[DRY-RUN] Would write → /prod/app/DB_HOST
[DRY-RUN] Would write → /prod/app/DB_PASSWORD
[DRY-RUN] Would write → /prod/api/JWT_SECRET

No parameters are modified.


Execute the Synchronization

Once satisfied:

SOURCE_REGION=ap-south-1 \
DESTINATION_REGION=us-east-1 \
PARAMETER_PREFIXES=/prod/app,/prod/api \
python bulk_sync_ssm.py

Example output:

[OK] /prod/app/DB_HOST
[OK] /prod/app/DB_PASSWORD
[OK] /prod/api/JWT_SECRET


Testing the Setup

Test Parameter Creation

aws ssm put-parameter \
  --name "/prod/app/DB_HOST" \
  --value "database.example.com" \
  --type String \
  --overwrite \
  --region ap-south-1

Verify:

aws ssm get-parameter \
  --name "/prod/app/DB_HOST" \
  --region us-east-1


Test Parameter Update

aws ssm put-parameter \
  --name "/prod/app/DB_HOST" \
  --value "new-database.example.com" \
  --type String \
  --overwrite \
  --region ap-south-1

Verify the updated value exists in the destination region.


Test Parameter Deletion

aws ssm delete-parameter \
  --name "/prod/app/DB_HOST" \
  --region ap-south-1

Verify:

aws ssm get-parameter \
  --name "/prod/app/DB_HOST" \
  --region us-east-1

Expected result:

ParameterNotFound


Common Issues

Lambda Never Triggers

Usually caused by:

  • CloudTrail disabled
  • EventBridge rule in the wrong region

AccessDeniedException

Ensure the Lambda role has:

ssm:GetParameter
ssm:GetParametersByPath
ssm:DescribeParameters
ssm:PutParameter
ssm:DeleteParameter
ssm:AddTagsToResource


InvalidKeyId

The destination KMS key does not exist.

Either:

  • Create the key
  • Use AWS-managed encryption

Parameters Not Replicating

Verify the parameter path matches one of the configured prefixes:

/prod/app
/prod/api


Security Recommendations

Use SecureString

Encrypt sensitive values:

  • Database passwords
  • API keys
  • Access tokens
  • JWT secrets

Restrict IAM Permissions

Avoid:

ssm:* on *

Instead:

arn:aws:ssm:*:*:parameter/prod/*


Enable Monitoring

Monitor:

  • Lambda errors
  • Replication failures
  • Unexpected Parameter Store changes

Operational visibility becomes invaluable when troubleshooting.


Cost Considerations

This solution is surprisingly inexpensive.

You're only using:

  • EventBridge
  • Lambda
  • CloudTrail
  • Parameter Store

There are no always-running resources.

For most teams, the monthly cost is negligible compared to the operational effort saved.


Complete Workflow

Day 0
│
├─ Enable CloudTrail
├─ Deploy Lambda
├─ Create EventBridge Rule
├─ Run bulk_sync_ssm.py
└─ Verify replication

Day 1+
│
├─ Developer creates parameter
│     └─ Replicated
│
├─ Developer updates parameter
│     └─ Replicated
│
├─ Developer deletes parameter
│     └─ Deleted from destination regions
│
└─ Regions remain synchronized automatically


Final Thoughts

AWS doesn't currently provide native cross-region replication for Parameter Store.

Fortunately, combining CloudTrail, EventBridge, and Lambda makes it easy to build your own event-driven synchronization mechanism.

The biggest lesson from implementing this in production is that configuration drift is rarely caused by technology—it is usually caused by manual processes.

By automating parameter replication for creates, updates, and deletions, you remove an entire category of operational risk and ensure that all regions remain synchronized automatically.

If you're running multi-region AWS workloads, this is one of those small automations that quietly saves hours of troubleshooting later.

Happy building 🚀


Acknowledgements

A special thanks to *@vikram_patel * for collaboration, testing, and valuable feedback during the implementation of this solution.