Skip to main content

5 posts tagged with "AWS"

View all tags

Building a Blog Comment API with AWS Serverless

· 3 min read
ひかり
Main bloger

I wanted to add a comment section to this blog, so instead of using an off-the-shelf solution like Disqus or giscus, I built my own API on AWS serverless. Here's a look at the design and implementation.

Architecture

Requests flow through the following stack:

Browser (www.hikari-dev.com)
↓ HTTPS
API Gateway
├── GET /comment?postId=... → Fetch comments
├── POST /comment → Submit a comment
└── PATCH /comment/{id} → Admin (toggle visibility)

Lambda (Node.js 20 / arm64)

DynamoDB (comment storage)
+ SES v2 (admin email notifications)

The code is written in TypeScript and managed as IaC with SAM (Serverless Application Model). Lambda runs on arm64 (Graviton2) to shave a bit off the cost.

DynamoDB Table Design

The table is named blog-comments, with postId as the partition key and commentId as the sort key.

KeyTypeDescription
postIdStringPost identifier (e.g. /blog/2026/03/20/hime)
commentIdStringULID (lexicographically sortable by time)

Using ULID for the sort key means comments retrieved with QueryCommand are automatically returned in chronological order — which is why I chose ULID over UUID.

Spam Filtering

Before writing a comment to DynamoDB, the handler checks it against a keyword list defined in keywords.json.

If a keyword matches, the comment is saved with isHidden: true and isFlagged: "1", hiding it automatically. If nothing matches, it goes live immediately.

isFlagged is used as the key for a Sparse GSI. Comments that pass the filter don't get this attribute at all, which keeps unnecessary partitions from appearing in the index — good for both cost and efficiency. This is achieved simply by setting removeUndefinedValues: true on the DynamoDB Document Client.

export const ddb = DynamoDBDocumentClient.from(client, {
marshallOptions: {
removeUndefinedValues: true,
},
});

Admin Email Notifications

Every time a comment is submitted, SES v2 sends me an email containing the author name, body, rating, IP address, and flag status.

The email is sent asynchronously, and any failure is silently swallowed. This keeps the POST response time unaffected by email delivery.

sendCommentNotification(record).catch((err) => {
console.error("sendCommentNotification error:", err);
});

Privacy

IP addresses and User-Agent strings are stored in DynamoDB for moderation purposes, but they are never included in GET responses. This separation is enforced at the type level.

Security

LayerMeasure
NetworkAWS WAF rate limit: 100 req / 5 min / IP
CORSRestricted to https://www.hikari-dev.com
Admin APIAPI Gateway API key auth (X-Api-Key header)
SpamKeyword filter with automatic hiding

For the admin endpoint (PATCH /comment/{id}), setting ApiKeyRequired: true in the SAM template is all it takes to enable API key authentication — no need to implement a custom Lambda Authorizer.

Wrap-up

The serverless setup means no server management, and DynamoDB's on-demand billing keeps costs minimal for a low-traffic personal blog.

The whole thing is packaged with SAM + TypeScript + esbuild, and deploying is as simple as sam build && sam deploy.

I Built a Cloud Storage Service with AWS Serverless

· 3 min read
ひかり
Main bloger

Introduction

I wanted a personal file sharing system, so I built a file storage service using only AWS serverless services.

In this article, I'll walk through the key design decisions and the actual architecture I ended up with.

What I Built

The cloud storage service that lets you upload, download, and manage folders through a web browser.

Key Features

  • File upload / download
  • Folder creation and hierarchical management
  • Bulk ZIP download of multiple files / folders
  • User authentication (sign-up, login, password reset)
  • User profile management

Architecture

Here's the architecture diagram.

Most of the authentication is handled by Cognito. For file transfers, Lambda issues S3 Presigned URLs so the client communicates directly with S3.

Tech Stack

LayerTechnology
BackendC# (.NET 8) / AWS Lambda
AuthenticationAmazon Cognito + Managed Login v2
APIAPI Gateway (REST) + Cognito Authorizer
StorageAmazon S3

Design Decisions and Reasoning

Using Cognito for Authentication

I leveraged Cognito's OAuth 2.0 endpoints and Managed Login to implement authentication.

In the end, I only needed a single Lambda function for auth: TokenFunction.

In terms of both functionality and security, less code is better. There's no need to write what AWS services already do for you.

File Transfers via Presigned URLs

Routing file uploads and downloads through Lambda introduces several problems:

  • Hitting Lambda's payload size limit
  • Loading large files into Lambda memory is costly
  • Transfer time counts against Lambda execution time

With Presigned URLs, Lambda only issues the URL — the actual file transfer happens directly between the browser and S3.

Lambda execution time stays in the tens of milliseconds, and the file size limit extends all the way to S3's own limits.

Upload flow:
1. Browser → Lambda: "I want to upload file.pdf! Send me an upload URL."
2. Lambda → Browser: "Here's a Presigned URL. PUT your file here."
3. Browser → S3: "Sending PUT to S3."
4. Browser → Lambda: "Upload complete!"

ZIP Download for Folders

S3 doesn't have a built-in feature to download an entire folder.

For bulk downloads, I generate a ZIP file in Lambda, temporarily store it in S3, and return a Presigned URL for it.

The temporary ZIP file is automatically deleted after 1 day via an S3 lifecycle rule, so there's no garbage buildup.

Security

MeasureImplementation
Brute-force protectionCognito's built-in lockout (5 failures: 15-minute lock)
API protectionJWT verification via Cognito Authorizer
CORSAllowedOrigin restricted to a specific domain
Temporary file managementS3 lifecycle rule auto-deletes files after 1 day

Cost

With a serverless architecture, costs are nearly zero when not in use.

  • Cognito: ESSENTIALS Tier is free up to 10,000 MAU
  • Lambda: Free up to 1 million requests per month
  • S3: Pay-as-you-go based on storage used (~$0.025/GB per month)
  • API Gateway: $3.50 per 1 million requests

For personal use, monthly costs should land somewhere between a few cents and a couple of dollars.

Infrastructure as Code

The entire infrastructure is defined in a single template.yaml (AWS SAM).

Cognito User Pool, API Gateway, 3 Lambda functions, S3 bucket, CloudWatch alarms, SNS — all resources defined in roughly 600 lines of YAML.

EC2 Instance Connect fails to connect from Windows without a key

· One min read

Unable to connect to Instance Connect on Windows

PS C:\> aws ec2-instance-connect ssh --instance-id i-0aa38de21acf2aa1c --region ap-south-1
Bad permissions. Try removing permissions for user: \\OWNER RIGHTS (S-1-3-4) on file C:/Users/hikari/AppData/Local/Temp/tmpm9m1bf7j/private-key.
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions for 'C:\\Users\\hikari\\AppData\\Local\\Temp\\tmpm9m1bf7j\\private-key' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Load key "C:\\Users\\hikari\\AppData\\Local\\Temp\\tmpm9m1bf7j\\private-key": bad permissions
[email protected]: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).

Verification as of 2025/06/11.

Login is possible from WSL

PS C:\> wsl -- aws ec2-instance-connect ssh --instance-id i-0aa38de21acf2aa1c --region ap-south-1
, #_
~\_ ####_ Amazon Linux 2023
~~ \_#####\
~~ \###|
~~ \#/ ___ https://aws.amazon.com/linux/amazon-linux-2023
~~ V~' '->
~~~ /
~~._. _/
_/ _/
_/m/'
Last login: Tue Jun 10 22:50:33 2025 from 192.168.0.183
[ec2-user@ip-192-168-0-4 ~]$

Why?

Addendum

Downgrading allowed connection.

I wish they would fix this.

Reference: https://github.com/aws/aws-cli/issues/9114

msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2-2.17.35.msi

EC2 Instance Connect Summary

· 3 min read

What is EC2 Instance Connect?

EC2 Instance Connect is a service designed to simplify SSH connections to AWS EC2 instances.

With traditional SSH connection methods, a public key needed to be pre-configured on the instance. However, EC2 Instance Connect allows you to send a temporary SSH public key to the instance to establish a connection. (However, an Instance Connect package needs to be installed, except for some AMIs).

How to Connect to an Instance

There are several ways to connect to an instance.

Direct connection from the internet requires passing through an Internet Gateway or a NAT Gateway. It also needs a public IP address and cannot be used in a private network environment.

Since the ssh command can be used, it's the simplest method.

ssh <username>@<public IP address>

② Connection via EC2 Instance Connect Endpoint

By using the AWS CLI to connect via an EC2 Instance Connect endpoint, a public IP address is not required.

This also helps save on costs (a few hundred yen per month).

You can connect using a command like the following with the AWS CLI, but you must first import a key pair and configure it for the instance.

For example, a specific connection method is possible with the following command:

aws ec2-instance-connect ssh --private-key-file .ssh/id_ed25519 --os-user <username> --instance-id <instance ID> --connection-type eice

Note: You must first obtain an access key and configure it using aws configure.

This connection method is best if you want to avoid connecting to the internet and wish to use a non-official AMI.

③ Instance Connect Connection from AWS Management Console

For Amazon Linux and Ubuntu, if you have an Instance Connect endpoint created, you can connect to the instance directly from the Management Console.

However, an Instance Connect package needs to be installed, except for some AMIs.

For details, refer to: https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ec2-instance-connect-set-up.html

④ Other Connection Methods

Connection from Session Manager

Two endpoints for Session Manager need to be set up, and an IAM role that allows connections from Session Manager must be attached to the instance.

Also, a Session Manager package needs to be installed, except for some AMIs.

Connection from EC2 Serial Console

Using the serial console allows direct connection to the instance. Be aware that if a password is not set, you won't even be able to log in.

Security Settings

Network ACL (Subnet where the instance resides)

By default, all traffic is allowed, so no specific configuration is needed if using the default settings.

The minimum required settings are as follows:

Inbound Rules

Inbound rules must allow SSH (port 22).

This allows communication to the instance's SSH server, which typically listens on port 22.

Outbound Rules

Outbound rules must allow custom TCP (ports 1024-65535).

1024-65535 is the port range used by the client side during an SSH connection.

Security Group (Instance)

Inbound Rules

Inbound rules must allow SSH (port 22).

This setting is absolutely necessary.

Outbound Rules

Security groups remember communication (stateful), so outbound rules are usually not required.

Security Group (EC2 Instance Connect Endpoint)

Inbound Rules

Not required due to statefulness.

Outbound Rules

SSH (port 22) must be allowed.

This allows communication to the instance's port 22.

Using Official Rocky Linux Images on AWS

· 5 min read

How to choose an AMI

Obtain the AMI from the official page.

https://rockylinux.org/ja-JP/download

Select the architecture for your instance (ARM (aarch64)) and choose AWS AMI under Cloud Images.

alt text

Filter by version number to find the appropriate one.

alt text

The AMI ID cannot be copied directly, so click the "Deploy" button and copy it from the AWS console.

Searching by AMI ID will show it.

alt text

It might be better to filter by owner.

Owner = 792107900819

alt text

Pre-requisites

  • Register a key pair
  • Run ssh-keygen -t ed25519 beforehand to create a public key, then import .ssh/id_ed25519.pub into your key pair.
  • Install AWS CLI
  • Install the CLI.
  • Configure access keys (aws configure).

Setting up the Network

An Elastic IP is cheaper than a NAT Gateway, so create an Elastic IP.

The network architecture looks like this:

Create an EC2 Instance Connect Endpoint

alt text

Creating an EC2 Instance Connect Endpoint allows you to log in from the AWS CLI.

Launching an Instance

  • Allow ICMP (Echo Request) to accept ping requests (Security Group).
  • Allow SSH connections (Security Group).
  • Mumbai region and arm64 instances are inexpensive.
  • Requires 1.5 GiB RAM per vCPU (at least t4g.medium).

Therefore, I launched an instance with the following conditions:

  • Region: Mumbai
  • Architecture: arm64
  • AMI: RHEL 8.10 (LVM, aarch64); ami-0415efd8380284dc4
  • Instance Type: t4g.medium
  • Key pair: Public key created on PC (.ssh/id_ed25519.pub)
  • Network: Public subnet (associated with a route table that defines a route to an internet gateway)
  • Security Group: Create a security group (default name)
  • SSH, 0.0.0.0/0
  • Custom ICMP - IPv4 (Echo request), 0.0.0.0/0
  • Storage: 1x 10GiB, gp3

Connection

Open your PC's terminal and run the following:

aws ec2-instance-connect ssh --private-key-file .ssh/id_ed25519 --os-user rocky --instance-id i-*****************

Install Instance Connect Package

The Rocky Linux AMI does not include the Instance Connect package, preventing connections from the Management Console. Therefore, the package must be installed.

Refer to https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ec2-instance-connect-set-up.html for instructions on downloading the package.

  • Note: Select the RHEL package.
  • Note: It may not work correctly if the OS major version or architecture differs.

Example

curl https://amazon-ec2-instance-connect-us-west-2.s3.us-west-2.amazonaws.com/latest/linux_arm64/ec2-instance-connect.rhel8.rpm -o /tmp/ec2-instance-connect.rpm
curl https://amazon-ec2-instance-connect-us-west-2.s3.us-west-2.amazonaws.com/latest/linux_amd64/ec2-instance-connect-selinux.noarch.rpm -o /tmp/ec2-instance-connect-selinux.rpm
sudo dnf install -y /tmp/ec2-instance-connect.rpm /tmp/ec2-instance-connect-selinux.rpm

Once installed, you will be able to access the instance from the Management Console.

alt text

CDK (typescript)

I've included the CDK code I created for reference.

Remember to change the keyName (key pair) name.

import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

export interface RockyLinuxStackProps extends cdk.StackProps {
}

export class RockyLinuxStack extends cdk.Stack {
public constructor(scope: cdk.App, id: string, props: RockyLinuxStackProps = {}) {
super(scope, id, props);

// Resources
const ec2dhcpOptions = new ec2.CfnDHCPOptions(this, 'EC2DHCPOptions', {
domainName: 'ap-south-1.compute.internal',
domainNameServers: [
'AmazonProvidedDNS',
],
],
});
ec2dhcpOptions.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2InternetGateway = new ec2.CfnInternetGateway(this, 'EC2InternetGateway', {
{
value: 'igw',
key: 'Name',
},
],
});
ec2InternetGateway.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2vpc = new ec2.CfnVPC(this, 'EC2VPC', {
cidrBlock: '10.0.0.0/16',
enableDnsSupport: true,
instanceTenancy: 'default',
enableDnsHostnames: true,
{
value: 'vpc',
key: 'Name',
},
],
});
ec2vpc.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2VPCGatewayAttachment = new ec2.CfnVPCGatewayAttachment(this, 'EC2VPCGatewayAttachment', {
vpcId: ec2vpc.ref,
internetGatewayId: ec2InternetGateway.ref,
});
ec2VPCGatewayAttachment.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2NetworkAcl = new ec2.CfnNetworkAcl(this, 'EC2NetworkAcl', {
vpcId: ec2vpc.ref,
],
});
ec2NetworkAcl.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2RouteTable = new ec2.CfnRouteTable(this, 'EC2RouteTable', {
vpcId: ec2vpc.ref,
});
ec2RouteTable.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2SecurityGroup = new ec2.CfnSecurityGroup(this, 'EC2SecurityGroup', {
groupDescription: 'launch-wizard-1 created 2025-04-27T00:11:58.641Z',
groupName: 'launch-wizard-1',
vpcId: ec2vpc.ref,
securityGroupIngress: [
{
cidrIp: '0.0.0.0/0',
ipProtocol: 'tcp',
fromPort: 22,
toPort: 22,
},
{
cidrIp: '0.0.0.0/0',
ipProtocol: 'icmp',
fromPort: 8,
toPort: -1,
},
],
securityGroupEgress: [
{
cidrIp: '0.0.0.0/0',
ipProtocol: '-1',
fromPort: -1,
toPort: -1,
},
],
});
ec2SecurityGroup.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2Subnet = new ec2.CfnSubnet(this, 'EC2Subnet', {
vpcId: ec2vpc.ref,
mapPublicIpOnLaunch: false,
enableDns64: false,
availabilityZoneId: 'aps1-az1',
privateDnsNameOptionsOnLaunch: {
EnableResourceNameDnsARecord: false,
HostnameType: 'ip-name',
EnableResourceNameDnsAAAARecord: false,
},
cidrBlock: '10.0.0.0/20',
ipv6Native: false,
{
value: 'subnet-public1-ap-south-1a',
key: 'Name',
},
],
});
ec2Subnet.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2InstanceConnectEndpoint = new ec2.CfnInstanceConnectEndpoint(this, 'EC2InstanceConnectEndpoint', {
preserveClientIp: false,
securityGroupIds: [
ec2SecurityGroup.attrGroupId,
],
subnetId: ec2Subnet.attrSubnetId,
});
ec2InstanceConnectEndpoint.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2vpcdhcpOptionsAssociation = new ec2.CfnVPCDHCPOptionsAssociation(this, 'EC2VPCDHCPOptionsAssociation', {
vpcId: ec2vpc.ref,
dhcpOptionsId: ec2dhcpOptions.ref,
});
ec2vpcdhcpOptionsAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2RouteHg = new ec2.CfnRoute(this, 'EC2RouteHG', {
routeTableId: ec2RouteTable.ref,
destinationCidrBlock: '0.0.0.0/0',
gatewayId: ec2InternetGateway.ref,
});
ec2RouteHg.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2SubnetNetworkAclAssociation = new ec2.CfnSubnetNetworkAclAssociation(this, 'EC2SubnetNetworkAclAssociation', {
networkAclId: ec2NetworkAcl.ref,
subnetId: ec2Subnet.ref,
});
ec2SubnetNetworkAclAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2SubnetRouteTableAssociation = new ec2.CfnSubnetRouteTableAssociation(this, 'EC2SubnetRouteTableAssociation', {
routeTableId: ec2RouteTable.ref,
subnetId: ec2Subnet.ref,
});
ec2SubnetRouteTableAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2Instance = new ec2.CfnInstance(this, 'EC2Instance', {
tenancy: 'default',
instanceInitiatedShutdownBehavior: 'stop',
cpuOptions: {
threadsPerCore: 1,
coreCount: 2,
},
blockDeviceMappings: [
{
ebs: {
volumeType: 'gp3',
iops: 3000,
volumeSize: 10,
encrypted: false,
deleteOnTermination: true,
},
deviceName: '/dev/sda1',
},
],
availabilityZone: 'ap-south-1a',
privateDnsNameOptions: {
enableResourceNameDnsARecord: false,
hostnameType: 'ip-name',
enableResourceNameDnsAaaaRecord: false,
},
ebsOptimized: true,
disableApiTermination: false,
keyName: 'hikari',
sourceDestCheck: true,
placementGroupName: '',
networkInterfaces: [
{
privateIpAddresses: [
{
privateIpAddress: '10.0.3.59',
primary: true,
},
],
secondaryPrivateIpAddressCount: 0,
deviceIndex: '0',
groupSet: [
ec2SecurityGroup.ref,
],
ipv6Addresses: [
],
subnetId: ec2Subnet.ref,
associatePublicIpAddress: true,
deleteOnTermination: true,
},
],
imageId: 'ami-0415efd8380284dc4',
instanceType: 't4g.medium',
monitoring: false,
],
creditSpecification: {
cpuCredits: 'unlimited',
},
});
ec2Instance.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2ElasticIp = new ec2.CfnEIP(this, 'EC2ElasticIp', {
domain: 'vpc',
{
key: 'Name',
value: 'elastic-ip',
},
],
});
ec2ElasticIp.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;

const ec2EipAssociation = new ec2.CfnEIPAssociation(this, 'EC2EipAssociation', {
eip: ec2ElasticIp.ref,
instanceId: ec2Instance.ref,
});
ec2EipAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
}
}