DEV Community

Yoshiyuki Watanabe for AWS Community Builders

Posted on Originally published at Medium

Trying MiniStack and StackPort

Introduction

I tried MiniStack, a free local AWS emulator, and StackPort, a browser-based viewer for its resources.

GitHub logo ministackorg / ministack

Ministack: Free, open-source local AWS emulator - 60+ services, Terraform compatible, real databases. Free forever. MIT licensed.

MiniStack — Free Open-Source AWS Emulator

MiniStack

Free, open-source local AWS emulator. Free forever.

60+ AWS services on a single port · Multi-account & multi-region · Terraform compatible · Real databases · MIT licensed

GitHub release Build Docker Pulls Docker Image Size License Python

Ministack.org · Docker Hub · LinkedIn


Why MiniStack?

LocalStack recently moved its core services behind a paid plan. If you relied on LocalStack Community for local development and CI/CD pipelines, MiniStack is your free alternative.

  • 60+ AWS services emulated on a single port (4566)
  • Drop-in compatible — works with boto3, AWS CLI, Terraform, CDK, Pulumi, any SDK
  • Multi-account & multi-region — a 12-digit access key becomes the account, the SigV4 region scopes the state; isolated tenants and regions on one endpoint, like real AWS
  • Amazon Bedrock locally — Converse / InvokeModel with AWS-exact wire shapes; point MINISTACK_BEDROCK_PROXY_URL at Ollama, llama.cpp, or vLLM and get real completions through the Bedrock API
  • Real infrastructure — RDS spins up actual Postgres/MySQL containers, ElastiCache spins…

GitHub logo DaviReisVieira / stackport

Open-source universal AWS resource browser for MiniStack, Floci, LocalStack, Moto, and real AWS

StackPort — Universal AWS Resource Browser

StackPort

Universal AWS resource browser for local emulators and real AWS accounts.

Browse, inspect, and manage resources across 35 AWS services with dedicated UIs for S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager.

CI PyPI Version Docker Pulls Docker Image Size License Python GitHub stars

Screenshots

Dashboard — Service overview with resource counts and health status StackPort Dashboard

Lambda Browser — Function list with config, aliases, versions, event sources, and invocation panel Lambda Resources

S3 Browser — File browser with upload, download, folder navigation, and tagging S3 Browser

Features

  • Browse and inspect resources across 35 AWS services
  • 8 dedicated service UIs for S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager
  • Write operations — upload/delete S3 objects, query DynamoDB, invoke Lambda, send/receive SQS messages
  • Real AWS support — connect to real AWS accounts with read-only mode by default
  • Per-endpoint authentication — default credentials, AWS profiles (SSO/AssumeRole), or static keys per endpoint
  • Guided lessons — hands-on AWS tutorials that run inside the console and…

This article builds the following pipeline:

  • EventBridge Scheduler invokes a Lambda function on a schedule

  • The Lambda function calls an API and publishes the result to SNS

  • SNS triggers a second Lambda function, which posts to a Slack Incoming Webhook

  • The API key and the Webhook URL are stored as SecureString parameters in SSM Parameter Store

What Are MiniStack and StackPort?

MiniStack

MiniStack is a local AWS emulator under the MIT license. You can use more than 60 AWS services directly from the AWS CLI, boto3, Terraform, and CDK.

MiniStack — The Best AWS Emulator | Free Open-Source LocalStack Alternative

The best AWS emulator and the best alternative to LocalStack. 60+ AWS services free forever — real Postgres, Redis, Docker containers, full Terraform / CDK / Pulumi support. MIT licensed.

favicon ministack.org

StackPort

StackPort is a tool for browsing the resources of local emulators and real AWS accounts in a browser. It connects to emulators other than MiniStack (such as LocalStack) and to real AWS accounts.

StackPort: open-source web console for AWS emulators

StackPort is a free, open-source web console for MiniStack, Floci, LocalStack, Moto and real AWS. Browse 35 services, use 13 dedicated views, and learn AWS with guided lessons. One Docker image.

favicon stackport.cloud

StackPort lets you browse the resources of 35 services.

Eight of them have dedicated screens: S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager. For example, CloudWatch Logs lets you drill down from log groups to log streams to log events.

The other services (such as SSM in this article) are shown as a searchable resource table with a JSON detail view.

Note that StackPort does not support EventBridge Scheduler. The events service only shows EventBridge rules and event buses, so I check the schedule with the CLI.

Architecture

Locally, I run a mock server (mock-api) in place of the external API. For Slack, I use a real Incoming Webhook, with separate channels for the local and AWS environments.

Local environment architecture

Two things differ between the local environment and real AWS:

  • External API: mock-api locally, API Gateway (MOCK integration) on real AWS. Both return GET /v1/status

  • Slack destination: #notify-demo-local locally, #notify-demo-aws on real AWS

The directory structure is as follows. Terraform is split into a shared module (modules/notify) and one directory per environment (environments/local and environments/aws). Each environment has its own state.

code/
├── lambda/                          # Shared: the same code runs locally and on real AWS
│   ├── fetch_status/
│   │   └── app.py
│   └── notify_slack/
│       └── app.py
├── mock-api/                        # Local only: mock server standing in for the external API
│   ├── Dockerfile
│   └── server.py
├── scripts/
│   ├── put-parameters-aws.sh        # AWS only: registers parameters in the real SSM
│   └── put-parameters.sh            # Local only: registers parameters in MiniStack's SSM
├── terraform/
│   ├── environments/
│   │   ├── aws/                     # For real AWS: points at real AWS
│   │   │   ├── main.tf
│   │   │   ├── outputs.tf
│   │   │   ├── providers.tf
│   │   │   ├── terraform.tfvars
│   │   │   ├── terraform.tfvars.example
│   │   │   └── variables.tf
│   │   └── local/                   # For local: points at MiniStack
│   │       ├── main.tf
│   │       ├── outputs.tf
│   │       ├── providers.tf
│   │       ├── terraform.tfvars
│   │       ├── terraform.tfvars.example
│   │       └── variables.tf
│   └── modules/
│       └── notify/                  # Shared: Lambda, SNS, Scheduler, IAM, and more
│           ├── main.tf
│           ├── outputs.tf
│           ├── variables.tf
│           └── versions.tf
└── docker-compose.yml               # Local only: starts MiniStack, StackPort, and mock-api
Enter fullscreen mode Exit fullscreen mode

Building the Local Environment

You need Docker (Docker Desktop, OrbStack, and so on), Terraform, and the AWS CLI.

Step 1: Start MiniStack, StackPort, and the Mock Server

Start three containers with docker-compose.yml.

services:
  ministack:
    image: ministackorg/ministack:latest
    ports:
      - "4566:4566"
    environment:
      - MINISTACK_REGION=ap-northeast-1
    healthcheck:
      test:
        [
          "CMD",
          "python",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')",
        ]
      interval: 5s
      timeout: 3s
      retries: 5

  stackport:
    image: davireis/stackport:latest
    ports:
      - "8080:8080"
    environment:
      - AWS_ENDPOINT_URL=http://ministack:4566
      - AWS_REGION=ap-northeast-1
      - AWS_ACCESS_KEY_ID=test
      - AWS_SECRET_ACCESS_KEY=test
    healthcheck:
      test:
        [
          "CMD",
          "python",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')",
        ]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped
    depends_on:
      ministack:
        condition: service_healthy

  # Mock server standing in for the external API
  mock-api:
    build: ./mock-api
    ports:
      - "8000:8000"
    environment:
      - EXPECTED_API_KEY=local-dummy-api-key
Enter fullscreen mode Exit fullscreen mode

The three containers are:

  • ministack: the local AWS emulator. It accepts AWS API calls on port 4566
  • stackport: a web UI for browsing MiniStack's resources. It opens on port 8080
  • mock-api: a mock server standing in for the external API. It runs on port 8000

mock-api is written with only the Python standard library. It has one endpoint that validates the x-api-key header and returns a status.

    def do_GET(self):
        if self.path == "/v1/status":
            if self.headers.get("x-api-key") != EXPECTED_API_KEY:
                return self._send(403, {"message": "Forbidden"})
            return self._send(200, {"status": "ok"})
        return self._send(404, {"message": "not found"})
Enter fullscreen mode Exit fullscreen mode

Start the containers.

cd code
docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8080 in a browser to see the StackPort dashboard.

StackPort dashboard

Step 2: Register SecureString Parameters in SSM Parameter Store

I register the API key and the Webhook URL outside Terraform.

If you create them as aws_ssm_parameter resources in Terraform, the values remain in the state file in plain text. Write-only arguments (value_wo) in Terraform 1.11 and later keep them out of the state, but I don't use them here.

I prepared a script (scripts/put-parameters.sh) to register them.

#!/usr/bin/env bash
set -euo pipefail

: "${SLACK_WEBHOOK_URL:?Set SLACK_WEBHOOK_URL}"

export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=ap-northeast-1
ENDPOINT=http://localhost:4566

aws --endpoint-url="$ENDPOINT" ssm put-parameter \
  --name /notify-demo/api-key \
  --type SecureString \
  --value local-dummy-api-key \
  --overwrite

aws --endpoint-url="$ENDPOINT" ssm put-parameter \
  --name /notify-demo/slack-webhook-url \
  --type SecureString \
  --value "$SLACK_WEBHOOK_URL" \
  --overwrite
Enter fullscreen mode Exit fullscreen mode

For the Webhook URL, I use an Incoming Webhook issued for a Slack channel dedicated to local testing (#notify-demo-local).

SLACK_WEBHOOK_URL=<Webhook URL of the local channel> ./scripts/put-parameters.sh
Enter fullscreen mode Exit fullscreen mode

Running the script adds the parameters.

StackPort SSM parameter list

The displayed value is ENCRYPTED: followed by a base64 string. It is not encrypted with KMS, and anyone can decode it back to the original value, so be careful when sharing screenshots.

A SecureString value as seen in StackPort

Step 3: Write the Lambda Code

This is the Lambda function that calls the API (fetch_status). It gets the API key from SSM and publishes the API response to SNS.

import json
import os
import urllib.request

import boto3

ssm = boto3.client("ssm")
sns = boto3.client("sns")

API_BASE_URL = os.environ["API_BASE_URL"]
API_KEY_PARAM = os.environ["API_KEY_PARAM"]
RESULT_TOPIC_ARN = os.environ["RESULT_TOPIC_ARN"]

_api_key = None


def get_api_key():
    # Fetch from SSM only on a cold start; use the cache afterwards
    global _api_key
    if _api_key is None:
        res = ssm.get_parameter(Name=API_KEY_PARAM, WithDecryption=True)
        _api_key = res["Parameter"]["Value"]
    return _api_key


def handler(event, context):
    req = urllib.request.Request(
        f"{API_BASE_URL}/v1/status",
        headers={"x-api-key": get_api_key()},
    )
    # 4xx/5xx raise HTTPError, which makes the Lambda invocation fail
    with urllib.request.urlopen(req, timeout=10) as res:
        result = json.loads(res.read())
    print(f"API status: {result['status']}")

    sns.publish(
        TopicArn=RESULT_TOPIC_ARN,
        Subject="API status check",
        Message=json.dumps(result, ensure_ascii=False),
    )
    return result
Enter fullscreen mode Exit fullscreen mode

This is the Lambda function for Slack notifications (notify_slack). It extracts the SNS message and posts it to the Webhook.

def handler(event, context):
    for record in event["Records"]:
        result = json.loads(record["Sns"]["Message"])
        now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
        text = f":white_check_mark: API status: {result['status']} ({now})"

        req = urllib.request.Request(
            get_webhook_url(),
            data=json.dumps({"text": text}).encode(),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=10):
            pass
        print(text)
Enter fullscreen mode Exit fullscreen mode

Neither piece of code contains any MiniStack-specific endpoint configuration. MiniStack injects AWS_ENDPOINT_URL into the Lambda execution environment, so boto3 automatically points at MiniStack. The same code can be deployed to real AWS as is.

Step 4: Create the Resources with Terraform

The resources live in the shared module (terraform/modules/notify/main.tf). I don't show every resource. Instead, I excerpt the points I designed to work on both MiniStack and real AWS.

The external API URL is passed through the module variable api_base_url.

resource "aws_lambda_function" "fetch_status" {
  function_name    = local.fetch_name
  role             = aws_iam_role.fetch_status.arn
  runtime          = "python3.14"
  handler          = "app.handler"
  filename         = data.archive_file.fetch_status.output_path
  source_code_hash = data.archive_file.fetch_status.output_base64sha256
  timeout          = 30

  environment {
    variables = {
      API_BASE_URL     = var.api_base_url
      API_KEY_PARAM    = local.api_key_param
      RESULT_TOPIC_ARN = aws_sns_topic.result.arn
    }
  }

  depends_on = [aws_cloudwatch_log_group.fetch_status]
}
Enter fullscreen mode Exit fullscreen mode

terraform/environments/ contains one directory per environment (local and aws). Each has a main.tf that calls the shared module and its own environment-specific settings.

The only differences are providers.tf, which decides the connection target, and terraform.tfvars, which decides the external API URL.

Because the directories are separate, the state is also separate for each environment.

In terraform/environments/local/providers.tf, the provider points only the services I use at MiniStack.

provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "ap-northeast-1"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    iam       = "http://localhost:4566"
    lambda    = "http://localhost:4566"
    logs      = "http://localhost:4566"
    scheduler = "http://localhost:4566"
    sns       = "http://localhost:4566"
    ssm       = "http://localhost:4566"
    sts       = "http://localhost:4566"
  }
}
Enter fullscreen mode Exit fullscreen mode

These are the settings that make it work with MiniStack:

  • access_key, secret_key: dummy values (test)

  • region: the region to deploy to

  • skip_credentials_validation: skips validating credentials through the STS API

  • skip_metadata_api_check: skips the check against the AWS metadata API

  • skip_requesting_account_id: skips retrieving the account ID

  • endpoints: points each service at MiniStack. Specify only the services you use

The local directory (terraform/environments/local/main.tf) calls the module. The external API URL is passed through the variable api_base_url.

module "notify" {
  source = "../../modules/notify"

  region       = "ap-northeast-1"
  api_base_url = var.api_base_url
}
Enter fullscreen mode Exit fullscreen mode

The variable's value goes in terraform/environments/local/terraform.tfvars. Because this file is not managed in Git, create it by copying the template (terraform.tfvars.example). The Lambda function runs inside the MiniStack container, so it reaches mock-api by its Compose service name.

api_base_url = "http://mock-api:8000"
Enter fullscreen mode Exit fullscreen mode

Apply it.

cd terraform/environments/local
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars if needed
terraform init
terraform apply
Enter fullscreen mode Exit fullscreen mode

Here is the result. The resources are created in MiniStack's default account ID (000000000000).

$ terraform apply
module.notify.data.archive_file.notify_slack: Reading...
module.notify.data.archive_file.fetch_status: Reading...
module.notify.data.archive_file.notify_slack: Read complete after 0s [id=68fb7c506afbeeab169fb65494efc63dd8acba90]
module.notify.data.archive_file.fetch_status: Read complete after 0s [id=013b111cb8285884b651e501fd6f84ad373f787a]
module.notify.data.aws_iam_policy_document.lambda_assume: Reading...
module.notify.data.aws_caller_identity.current: Reading...
module.notify.data.aws_iam_policy_document.lambda_assume: Read complete after 0s [id=2690255455]
module.notify.data.aws_caller_identity.current: Read complete after 0s [id=000000000000]
module.notify.data.aws_iam_policy_document.scheduler_assume: Reading...
module.notify.data.aws_iam_policy_document.notify_slack: Reading...
module.notify.data.aws_iam_policy_document.scheduler_assume: Read complete after 0s [id=644537899]
module.notify.data.aws_iam_policy_document.notify_slack: Read complete after 0s [id=2364181876]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # module.notify.data.aws_iam_policy_document.fetch_status will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "fetch_status" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)

      + statement {
          + actions   = [
              + "ssm:GetParameter",
            ]
          + resources = [
              + "arn:aws:ssm:ap-northeast-1:000000000000:parameter/notify-demo/api-key",
            ]
        }
      + statement {
          + actions   = [
              + "sns:Publish",
            ]
          + resources = [
              + (known after apply),
            ]
        }
    }

  # module.notify.data.aws_iam_policy_document.scheduler will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "scheduler" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)

      + statement {
          + actions   = [
              + "lambda:InvokeFunction",
            ]
          + resources = [
              + (known after apply),
            ]
        }
    }

  # module.notify.aws_cloudwatch_log_group.fetch_status will be created
  + resource "aws_cloudwatch_log_group" "fetch_status" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/aws/lambda/notify-demo-fetch-status"
      + name_prefix                 = (known after apply)
      + region                      = "ap-northeast-1"
      + retention_in_days           = 7
      + skip_destroy                = false
      + tags_all                    = (known after apply)
    }

  # module.notify.aws_cloudwatch_log_group.notify_slack will be created
  + resource "aws_cloudwatch_log_group" "notify_slack" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/aws/lambda/notify-demo-notify-slack"
      + name_prefix                 = (known after apply)
      + region                      = "ap-northeast-1"
      + retention_in_days           = 7
      + skip_destroy                = false
      + tags_all                    = (known after apply)
    }

  # module.notify.aws_iam_role.fetch_status will be created
  + resource "aws_iam_role" "fetch_status" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "lambda.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "notify-demo-fetch-status-role"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.notify.aws_iam_role.notify_slack will be created
  + resource "aws_iam_role" "notify_slack" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "lambda.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "notify-demo-notify-slack-role"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.notify.aws_iam_role.scheduler will be created
  + resource "aws_iam_role" "scheduler" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "000000000000"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "scheduler.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "notify-demo-scheduler-role"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.notify.aws_iam_role_policy.fetch_status will be created
  + resource "aws_iam_role_policy" "fetch_status" {
      + id          = (known after apply)
      + name        = (known after apply)
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.notify.aws_iam_role_policy.notify_slack will be created
  + resource "aws_iam_role_policy" "notify_slack" {
      + id          = (known after apply)
      + name        = (known after apply)
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = "ssm:GetParameter"
                      + Effect   = "Allow"
                      + Resource = "arn:aws:ssm:ap-northeast-1:000000000000:parameter/notify-demo/slack-webhook-url"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.notify.aws_iam_role_policy.scheduler will be created
  + resource "aws_iam_role_policy" "scheduler" {
      + id          = (known after apply)
      + name        = (known after apply)
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.notify.aws_iam_role_policy_attachment.fetch_status_logs will be created
  + resource "aws_iam_role_policy_attachment" "fetch_status_logs" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      + role       = "notify-demo-fetch-status-role"
    }

  # module.notify.aws_iam_role_policy_attachment.notify_slack_logs will be created
  + resource "aws_iam_role_policy_attachment" "notify_slack_logs" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      + role       = "notify-demo-notify-slack-role"
    }

  # module.notify.aws_lambda_function.fetch_status will be created
  + resource "aws_lambda_function" "fetch_status" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + code_sha256                    = (known after apply)
      + filename                       = "./build/fetch_status.zip"
      + function_name                  = "notify-demo-fetch-status"
      + handler                        = "app.handler"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 128
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + qualified_invoke_arn           = (known after apply)
      + region                         = "ap-northeast-1"
      + reserved_concurrent_executions = -1
      + response_streaming_invoke_arn  = (known after apply)
      + role                           = (known after apply)
      + runtime                        = "python3.14"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + skip_destroy                   = false
      + source_code_hash               = "So5oKi3b/bEDekVIzzKxoKwo1aO2n/JdG+Ioj2nhtVw="
      + source_code_size               = (known after apply)
      + tags_all                       = (known after apply)
      + timeout                        = 30
      + version                        = (known after apply)

      + environment {
          + variables = (known after apply)
        }

      + ephemeral_storage (known after apply)

      + logging_config (known after apply)

      + tracing_config (known after apply)
    }

  # module.notify.aws_lambda_function.notify_slack will be created
  + resource "aws_lambda_function" "notify_slack" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + code_sha256                    = (known after apply)
      + filename                       = "./build/notify_slack.zip"
      + function_name                  = "notify-demo-notify-slack"
      + handler                        = "app.handler"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 128
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + qualified_invoke_arn           = (known after apply)
      + region                         = "ap-northeast-1"
      + reserved_concurrent_executions = -1
      + response_streaming_invoke_arn  = (known after apply)
      + role                           = (known after apply)
      + runtime                        = "python3.14"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + skip_destroy                   = false
      + source_code_hash               = "M+ENHhkFRJqThFhPFiREbrEpiqqEf0cJLzdvTNzrMUY="
      + source_code_size               = (known after apply)
      + tags_all                       = (known after apply)
      + timeout                        = 30
      + version                        = (known after apply)

      + environment {
          + variables = {
              + "TZ"                = "Asia/Tokyo"
              + "WEBHOOK_URL_PARAM" = "/notify-demo/slack-webhook-url"
            }
        }

      + ephemeral_storage (known after apply)

      + logging_config (known after apply)

      + tracing_config (known after apply)
    }

  # module.notify.aws_lambda_permission.notify_slack_from_sns will be created
  + resource "aws_lambda_permission" "notify_slack_from_sns" {
      + action              = "lambda:InvokeFunction"
      + function_name       = "notify-demo-notify-slack"
      + id                  = (known after apply)
      + principal           = "sns.amazonaws.com"
      + region              = "ap-northeast-1"
      + source_arn          = (known after apply)
      + statement_id        = "AllowExecutionFromSNS"
      + statement_id_prefix = (known after apply)
    }

  # module.notify.aws_scheduler_schedule.fetch_status will be created
  + resource "aws_scheduler_schedule" "fetch_status" {
      + action_after_completion      = (known after apply)
      + arn                          = (known after apply)
      + group_name                   = (known after apply)
      + id                           = (known after apply)
      + name                         = "notify-demo-fetch-status"
      + name_prefix                  = (known after apply)
      + region                       = "ap-northeast-1"
      + schedule_expression          = "rate(1 minute)"
      + schedule_expression_timezone = "UTC"
      + state                        = "ENABLED"

      + flexible_time_window {
          + mode = "OFF"
        }

      + target {
          + arn      = (known after apply)
          + input    = jsonencode(
                {
                  + source = "scheduler"
                }
            )
          + role_arn = (known after apply)
        }
    }

  # module.notify.aws_sns_topic.result will be created
  + resource "aws_sns_topic" "result" {
      + arn                         = (known after apply)
      + beginning_archive_time      = (known after apply)
      + content_based_deduplication = false
      + fifo_throughput_scope       = (known after apply)
      + fifo_topic                  = false
      + id                          = (known after apply)
      + maximum_message_size        = (known after apply)
      + name                        = "notify-demo-result"
      + name_prefix                 = (known after apply)
      + owner                       = (known after apply)
      + policy                      = (known after apply)
      + region                      = "ap-northeast-1"
      + signature_version           = (known after apply)
      + tags_all                    = (known after apply)
      + tracing_config              = (known after apply)
    }

  # module.notify.aws_sns_topic_subscription.result_to_notify_slack will be created
  + resource "aws_sns_topic_subscription" "result_to_notify_slack" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = (known after apply)
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "lambda"
      + raw_message_delivery            = false
      + region                          = "ap-northeast-1"
      + topic_arn                       = (known after apply)
    }

Plan: 16 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + fetch_status_function_name = "notify-demo-fetch-status"

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.notify.aws_cloudwatch_log_group.notify_slack: Creating...
module.notify.aws_cloudwatch_log_group.fetch_status: Creating...
module.notify.aws_iam_role.fetch_status: Creating...
module.notify.aws_iam_role.scheduler: Creating...
module.notify.aws_sns_topic.result: Creating...
module.notify.aws_iam_role.notify_slack: Creating...
module.notify.aws_iam_role.fetch_status: Creation complete after 0s [id=notify-demo-fetch-status-role]
module.notify.aws_iam_role.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role]
module.notify.aws_iam_role.notify_slack: Creation complete after 0s [id=notify-demo-notify-slack-role]
module.notify.aws_cloudwatch_log_group.fetch_status: Creation complete after 0s [id=/aws/lambda/notify-demo-fetch-status]
module.notify.aws_cloudwatch_log_group.notify_slack: Creation complete after 0s [id=/aws/lambda/notify-demo-notify-slack]
module.notify.aws_sns_topic.result: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:000000000000:notify-demo-result]
module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creating...
module.notify.data.aws_iam_policy_document.fetch_status: Reading...
module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creating...
module.notify.aws_iam_role_policy.notify_slack: Creating...
module.notify.data.aws_iam_policy_document.fetch_status: Read complete after 0s [id=193309822]
module.notify.aws_lambda_function.notify_slack: Creating...
module.notify.aws_lambda_function.fetch_status: Creating...
module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creation complete after 0s [id=notify-demo-fetch-status-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
module.notify.aws_iam_role_policy.notify_slack: Creation complete after 0s [id=notify-demo-notify-slack-role:terraform-0c334b43e9f77ddf68c66755f5]
module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creation complete after 0s [id=notify-demo-notify-slack-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
module.notify.aws_iam_role_policy.fetch_status: Creating...
module.notify.aws_iam_role_policy.fetch_status: Creation complete after 0s [id=notify-demo-fetch-status-role:terraform-5e557969d0acd65274a217f306]
module.notify.aws_lambda_function.notify_slack: Creation complete after 5s [id=notify-demo-notify-slack]
module.notify.aws_lambda_permission.notify_slack_from_sns: Creating...
module.notify.aws_lambda_permission.notify_slack_from_sns: Creation complete after 0s [id=AllowExecutionFromSNS]
module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creating...
module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:000000000000:notify-demo-result:8876cdbc-24f6-49f7-b09e-14fb507ff76e]
module.notify.aws_lambda_function.fetch_status: Still creating... [00m10s elapsed]
module.notify.aws_lambda_function.fetch_status: Creation complete after 10s [id=notify-demo-fetch-status]
module.notify.data.aws_iam_policy_document.scheduler: Reading...
module.notify.data.aws_iam_policy_document.scheduler: Read complete after 0s [id=1455079044]
module.notify.aws_scheduler_schedule.fetch_status: Creating...
module.notify.aws_iam_role_policy.scheduler: Creating...
module.notify.aws_scheduler_schedule.fetch_status: Creation complete after 0s [id=default/notify-demo-fetch-status]
module.notify.aws_iam_role_policy.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role:terraform-db943e5a1f8c21b3a1a29f7c69]

Apply complete! Resources: 16 added, 0 changed, 0 destroyed.

Outputs:

fetch_status_function_name = "notify-demo-fetch-status"
Enter fullscreen mode Exit fullscreen mode

Two functions appear on the StackPort Lambda screen.

StackPort Lambda function list

StackPort does not show schedules, so I check them with the CLI.

$ aws --endpoint-url=http://localhost:4566 --region ap-northeast-1 \
>   scheduler list-schedules
{
    "Schedules": [
        {
            "Arn": "arn:aws:scheduler:ap-northeast-1:000000000000:schedule/default/notify-demo-fetch-status",
            "CreationDate": "2026-10-12T09:56:36.343454+09:00",
            "GroupName": "default",
            "LastModificationDate": "2026-10-12T09:56:36.343454+09:00",
            "Name": "notify-demo-fetch-status",
            "State": "ENABLED",
            "Target": {
                "Arn": "arn:aws:lambda:ap-northeast-1:000000000000:function:notify-demo-fetch-status"
            }
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Verify the Behavior

After a short wait, API status: ok is posted to #notify-demo-local.

Notification posted to Slack from the local environment

You can check the Lambda execution logs on the StackPort CloudWatch Logs screen. First, the log group list shows /aws/lambda/notify-demo-notify-slack and /aws/lambda/notify-demo-fetch-status.

StackPort log group list

Opening /aws/lambda/notify-demo-notify-slack shows a list of log streams, one per execution.

StackPort log stream list

Opening a log stream shows START, END, and REPORT, plus what the Lambda function printed: the same API status: ok (…) that went to Slack.

StackPort log events

Deploying to Real AWS and Verifying the Same Behavior

I deploy the pipeline I verified locally to a real AWS account and check that it behaves the same way.

The Lambda code and the Terraform module are shared with the local environment. Only the environment directory (environments/aws) and the values registered in SSM change.

Real AWS architecture

The thick red borders show what differs from the local environment. An API Gateway created in advance stands in for the external API, and Slack notifications go to the AWS channel (#notify-demo-aws).

Step 1: Register the SSM Parameters

The API key was generated by the API Gateway. I retrieve its value and register it in SSM as a SecureString, together with the Incoming Webhook URL of the AWS channel.

Apart from retrieving the API key, the script is the same as the local one, so I omit its contents.

API_KEY_ID=<API key ID> SLACK_WEBHOOK_URL=<Webhook URL of the AWS channel> ./scripts/put-parameters-aws.sh
Enter fullscreen mode Exit fullscreen mode

This is the Parameter Store in the AWS console after registration.

AWS SSM parameter list

Opening a parameter shows its SecureString value as ********. Turn on "Show decrypted value" to see the value.

AWS SSM parameter details

Step 2: Deploy

terraform/environments/aws/providers.tf has no MiniStack-specific settings.

provider "aws" {
  region = "ap-northeast-1"
}
Enter fullscreen mode Exit fullscreen mode

The module call (terraform/environments/aws/main.tf) is the same as the local one.

module "notify" {
  source = "../../modules/notify"

  region       = "ap-northeast-1"
  api_base_url = var.api_base_url
}
Enter fullscreen mode Exit fullscreen mode

Only the external API URL changes, in terraform/environments/aws/terraform.tfvars. This file is not managed in Git either, so create it by copying the template (terraform.tfvars.example) and rewrite it with the URL of the API Gateway you created in advance.

api_base_url = "https://<API ID>.execute-api.ap-northeast-1.amazonaws.com/prod"
Enter fullscreen mode Exit fullscreen mode

Apply it.

cd terraform/environments/aws
cp terraform.tfvars.example terraform.tfvars
# Rewrite <API ID> in terraform.tfvars
terraform init
terraform apply
Enter fullscreen mode Exit fullscreen mode

For comparison with the apply against MiniStack, here is the result. The account ID is masked.

$ terraform apply
module.notify.data.archive_file.fetch_status: Reading...
module.notify.data.archive_file.notify_slack: Reading...
module.notify.data.archive_file.notify_slack: Read complete after 0s [id=68fb7c506afbeeab169fb65494efc63dd8acba90]
module.notify.data.archive_file.fetch_status: Read complete after 0s [id=013b111cb8285884b651e501fd6f84ad373f787a]
module.notify.data.aws_iam_policy_document.lambda_assume: Reading...
module.notify.data.aws_caller_identity.current: Reading...
module.notify.data.aws_iam_policy_document.lambda_assume: Read complete after 0s [id=2690255455]
module.notify.data.aws_caller_identity.current: Read complete after 0s [id=123456789012]
module.notify.data.aws_iam_policy_document.scheduler_assume: Reading...
module.notify.data.aws_iam_policy_document.notify_slack: Reading...
module.notify.data.aws_iam_policy_document.notify_slack: Read complete after 0s [id=3754217979]
module.notify.data.aws_iam_policy_document.scheduler_assume: Read complete after 0s [id=991516426]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # module.notify.data.aws_iam_policy_document.fetch_status will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "fetch_status" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)

      + statement {
          + actions   = [
              + "ssm:GetParameter",
            ]
          + resources = [
              + "arn:aws:ssm:ap-northeast-1:123456789012:parameter/notify-demo/api-key",
            ]
        }
      + statement {
          + actions   = [
              + "sns:Publish",
            ]
          + resources = [
              + (known after apply),
            ]
        }
    }

  # module.notify.data.aws_iam_policy_document.scheduler will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "scheduler" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)

      + statement {
          + actions   = [
              + "lambda:InvokeFunction",
            ]
          + resources = [
              + (known after apply),
            ]
        }
    }

  # module.notify.aws_cloudwatch_log_group.fetch_status will be created
  + resource "aws_cloudwatch_log_group" "fetch_status" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/aws/lambda/notify-demo-fetch-status"
      + name_prefix                 = (known after apply)
      + region                      = "ap-northeast-1"
      + retention_in_days           = 7
      + skip_destroy                = false
      + tags_all                    = (known after apply)
    }

  # module.notify.aws_cloudwatch_log_group.notify_slack will be created
  + resource "aws_cloudwatch_log_group" "notify_slack" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/aws/lambda/notify-demo-notify-slack"
      + name_prefix                 = (known after apply)
      + region                      = "ap-northeast-1"
      + retention_in_days           = 7
      + skip_destroy                = false
      + tags_all                    = (known after apply)
    }

  # module.notify.aws_iam_role.fetch_status will be created
  + resource "aws_iam_role" "fetch_status" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "lambda.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "notify-demo-fetch-status-role"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.notify.aws_iam_role.notify_slack will be created
  + resource "aws_iam_role" "notify_slack" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "lambda.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "notify-demo-notify-slack-role"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.notify.aws_iam_role.scheduler will be created
  + resource "aws_iam_role" "scheduler" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "123456789012"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "scheduler.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "notify-demo-scheduler-role"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.notify.aws_iam_role_policy.fetch_status will be created
  + resource "aws_iam_role_policy" "fetch_status" {
      + id          = (known after apply)
      + name        = (known after apply)
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.notify.aws_iam_role_policy.notify_slack will be created
  + resource "aws_iam_role_policy" "notify_slack" {
      + id          = (known after apply)
      + name        = (known after apply)
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = "ssm:GetParameter"
                      + Effect   = "Allow"
                      + Resource = "arn:aws:ssm:ap-northeast-1:123456789012:parameter/notify-demo/slack-webhook-url"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.notify.aws_iam_role_policy.scheduler will be created
  + resource "aws_iam_role_policy" "scheduler" {
      + id          = (known after apply)
      + name        = (known after apply)
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.notify.aws_iam_role_policy_attachment.fetch_status_logs will be created
  + resource "aws_iam_role_policy_attachment" "fetch_status_logs" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      + role       = "notify-demo-fetch-status-role"
    }

  # module.notify.aws_iam_role_policy_attachment.notify_slack_logs will be created
  + resource "aws_iam_role_policy_attachment" "notify_slack_logs" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      + role       = "notify-demo-notify-slack-role"
    }

  # module.notify.aws_lambda_function.fetch_status will be created
  + resource "aws_lambda_function" "fetch_status" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + code_sha256                    = (known after apply)
      + filename                       = "./build/fetch_status.zip"
      + function_name                  = "notify-demo-fetch-status"
      + handler                        = "app.handler"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 128
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + qualified_invoke_arn           = (known after apply)
      + region                         = "ap-northeast-1"
      + reserved_concurrent_executions = -1
      + response_streaming_invoke_arn  = (known after apply)
      + role                           = (known after apply)
      + runtime                        = "python3.14"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + skip_destroy                   = false
      + source_code_hash               = "So5oKi3b/bEDekVIzzKxoKwo1aO2n/JdG+Ioj2nhtVw="
      + source_code_size               = (known after apply)
      + tags_all                       = (known after apply)
      + timeout                        = 30
      + version                        = (known after apply)

      + environment {
          + variables = (known after apply)
        }

      + ephemeral_storage (known after apply)

      + logging_config (known after apply)

      + tracing_config (known after apply)
    }

  # module.notify.aws_lambda_function.notify_slack will be created
  + resource "aws_lambda_function" "notify_slack" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + code_sha256                    = (known after apply)
      + filename                       = "./build/notify_slack.zip"
      + function_name                  = "notify-demo-notify-slack"
      + handler                        = "app.handler"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 128
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + qualified_invoke_arn           = (known after apply)
      + region                         = "ap-northeast-1"
      + reserved_concurrent_executions = -1
      + response_streaming_invoke_arn  = (known after apply)
      + role                           = (known after apply)
      + runtime                        = "python3.14"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + skip_destroy                   = false
      + source_code_hash               = "M+ENHhkFRJqThFhPFiREbrEpiqqEf0cJLzdvTNzrMUY="
      + source_code_size               = (known after apply)
      + tags_all                       = (known after apply)
      + timeout                        = 30
      + version                        = (known after apply)

      + environment {
          + variables = {
              + "TZ"                = "Asia/Tokyo"
              + "WEBHOOK_URL_PARAM" = "/notify-demo/slack-webhook-url"
            }
        }

      + ephemeral_storage (known after apply)

      + logging_config (known after apply)

      + tracing_config (known after apply)
    }

  # module.notify.aws_lambda_permission.notify_slack_from_sns will be created
  + resource "aws_lambda_permission" "notify_slack_from_sns" {
      + action              = "lambda:InvokeFunction"
      + function_name       = "notify-demo-notify-slack"
      + id                  = (known after apply)
      + principal           = "sns.amazonaws.com"
      + region              = "ap-northeast-1"
      + source_arn          = (known after apply)
      + statement_id        = "AllowExecutionFromSNS"
      + statement_id_prefix = (known after apply)
    }

  # module.notify.aws_scheduler_schedule.fetch_status will be created
  + resource "aws_scheduler_schedule" "fetch_status" {
      + action_after_completion      = (known after apply)
      + arn                          = (known after apply)
      + group_name                   = (known after apply)
      + id                           = (known after apply)
      + name                         = "notify-demo-fetch-status"
      + name_prefix                  = (known after apply)
      + region                       = "ap-northeast-1"
      + schedule_expression          = "rate(1 minute)"
      + schedule_expression_timezone = "UTC"
      + state                        = "ENABLED"

      + flexible_time_window {
          + mode = "OFF"
        }

      + target {
          + arn      = (known after apply)
          + input    = jsonencode(
                {
                  + source = "scheduler"
                }
            )
          + role_arn = (known after apply)
        }
    }

  # module.notify.aws_sns_topic.result will be created
  + resource "aws_sns_topic" "result" {
      + arn                         = (known after apply)
      + beginning_archive_time      = (known after apply)
      + content_based_deduplication = false
      + fifo_throughput_scope       = (known after apply)
      + fifo_topic                  = false
      + id                          = (known after apply)
      + maximum_message_size        = (known after apply)
      + name                        = "notify-demo-result"
      + name_prefix                 = (known after apply)
      + owner                       = (known after apply)
      + policy                      = (known after apply)
      + region                      = "ap-northeast-1"
      + signature_version           = (known after apply)
      + tags_all                    = (known after apply)
      + tracing_config              = (known after apply)
    }

  # module.notify.aws_sns_topic_subscription.result_to_notify_slack will be created
  + resource "aws_sns_topic_subscription" "result_to_notify_slack" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = (known after apply)
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "lambda"
      + raw_message_delivery            = false
      + region                          = "ap-northeast-1"
      + topic_arn                       = (known after apply)
    }

Plan: 16 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + fetch_status_function_name = "notify-demo-fetch-status"

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.notify.aws_cloudwatch_log_group.fetch_status: Creating...
module.notify.aws_iam_role.fetch_status: Creating...
module.notify.aws_iam_role.notify_slack: Creating...
module.notify.aws_cloudwatch_log_group.notify_slack: Creating...
module.notify.aws_sns_topic.result: Creating...
module.notify.aws_iam_role.scheduler: Creating...
module.notify.aws_cloudwatch_log_group.fetch_status: Creation complete after 0s [id=/aws/lambda/notify-demo-fetch-status]
module.notify.aws_cloudwatch_log_group.notify_slack: Creation complete after 0s [id=/aws/lambda/notify-demo-notify-slack]
module.notify.aws_sns_topic.result: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:123456789012:notify-demo-result]
module.notify.data.aws_iam_policy_document.fetch_status: Reading...
module.notify.data.aws_iam_policy_document.fetch_status: Read complete after 0s [id=3535791877]
module.notify.aws_iam_role.notify_slack: Creation complete after 1s [id=notify-demo-notify-slack-role]
module.notify.aws_iam_role.scheduler: Creation complete after 1s [id=notify-demo-scheduler-role]
module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creating...
module.notify.aws_iam_role_policy.notify_slack: Creating...
module.notify.aws_lambda_function.notify_slack: Creating...
module.notify.aws_iam_role.fetch_status: Creation complete after 1s [id=notify-demo-fetch-status-role]
module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creating...
module.notify.aws_iam_role_policy.fetch_status: Creating...
module.notify.aws_lambda_function.fetch_status: Creating...
module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creation complete after 1s [id=notify-demo-notify-slack-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creation complete after 1s [id=notify-demo-fetch-status-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
module.notify.aws_iam_role_policy.notify_slack: Creation complete after 1s [id=notify-demo-notify-slack-role:terraform-22dd2cc9194bee349a41b83b49]
module.notify.aws_iam_role_policy.fetch_status: Creation complete after 1s [id=notify-demo-fetch-status-role:terraform-70ee3cf4543eabb9406936afc3]
module.notify.aws_lambda_function.notify_slack: Still creating... [00m10s elapsed]
module.notify.aws_lambda_function.fetch_status: Still creating... [00m10s elapsed]
module.notify.aws_lambda_function.notify_slack: Creation complete after 15s [id=notify-demo-notify-slack]
module.notify.aws_lambda_permission.notify_slack_from_sns: Creating...
module.notify.aws_lambda_permission.notify_slack_from_sns: Creation complete after 0s [id=AllowExecutionFromSNS]
module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creating...
module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:123456789012:notify-demo-result:c13350e2-427f-446b-b1ff-341576727510]
module.notify.aws_lambda_function.fetch_status: Still creating... [00m20s elapsed]
module.notify.aws_lambda_function.fetch_status: Creation complete after 21s [id=notify-demo-fetch-status]
module.notify.data.aws_iam_policy_document.scheduler: Reading...
module.notify.data.aws_iam_policy_document.scheduler: Read complete after 0s [id=2990400390]
module.notify.aws_scheduler_schedule.fetch_status: Creating...
module.notify.aws_iam_role_policy.scheduler: Creating...
module.notify.aws_scheduler_schedule.fetch_status: Creation complete after 0s [id=default/notify-demo-fetch-status]
module.notify.aws_iam_role_policy.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role:terraform-229c8ea50921716c957135f6a7]

Apply complete! Resources: 16 added, 0 changed, 0 destroyed.

Outputs:

fetch_status_function_name = "notify-demo-fetch-status"
Enter fullscreen mode Exit fullscreen mode

I check the schedule with the CLI, as I did locally. The account ID is masked.

$ aws --region ap-northeast-1 scheduler list-schedules
{
    "Schedules": [
        {
            "Arn": "arn:aws:scheduler:ap-northeast-1:123456789012:schedule/default/notify-demo-fetch-status",
            "CreationDate": "2026-10-12T10:28:05.265000+09:00",
            "GroupName": "default",
            "LastModificationDate": "2026-10-12T10:28:05.265000+09:00",
            "Name": "notify-demo-fetch-status",
            "State": "ENABLED",
            "Target": {
                "Arn": "arn:aws:lambda:ap-northeast-1:123456789012:function:notify-demo-fetch-status"
            }
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Verify the Behavior

After waiting about a minute from the first invocation, API status: ok is posted to #notify-demo-aws.

Notification posted to Slack

You can check the Lambda logs in CloudWatch Logs. In the console, open the log group, the log stream, and the log events in that order.

AWS log group list

AWS log stream list

AWS log events

Summary

  • I built the pipeline from Scheduler through Lambda and SNS to Slack notifications locally with MiniStack. The Lambda code is shared with real AWS, and Terraform uses a shared module with one directory per environment, so the pipeline could be deployed to real AWS just by switching directories.
  • With StackPort, you can follow Lambda logs and SSM parameters on screen, which makes it easier to see what is going on than with the CLI alone.
  • That said, MiniStack and StackPort do not simulate AWS completely. For example, SecureString values are not encrypted, and StackPort cannot display Scheduler. I recommend understanding what you can test locally, and then verifying on real AWS as well.

Thank you for reading this far!

Top comments (0)