<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Well-Architected]]></title><description><![CDATA[Deep dives into building resilient, scalable and sustainable systems. Well-Architected explores the modern web and robust AWS infrastructure, focusing on best p]]></description><link>https://blog.hazya.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1771109717685/b6bfb15b-a95a-4cc7-bb8a-724077e517aa.png</url><title>Well-Architected</title><link>https://blog.hazya.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 20 Apr 2026 13:54:08 GMT</lastBuildDate><atom:link href="https://blog.hazya.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Amazon S3 Files for Stateful Containers]]></title><description><![CDATA[Deploying stateless containers on AWS is a straightforward process, but deploying stateful applications on serverless containers has always been a bit of a "choose your own adventure" when it comes to]]></description><link>https://blog.hazya.dev/amazon-s3-files-for-stateful-containers</link><guid isPermaLink="true">https://blog.hazya.dev/amazon-s3-files-for-stateful-containers</guid><category><![CDATA[AWS]]></category><category><![CDATA[CloudComputing]]></category><category><![CDATA[serverless]]></category><category><![CDATA[WordPress]]></category><category><![CDATA[aws-cdk]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Sun, 19 Apr 2026 10:55:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/70e948f7-16ac-4fad-b67b-f16378adc1df.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Deploying stateless containers on AWS is a straightforward process, but deploying stateful applications on serverless containers has always been a bit of a "choose your own adventure" when it comes to storage. That's especially true for CMS applications like WordPress and Strapi, or any application that expects a writable shared filesystem. The database is only part of the story; user uploads, plugins, themes, and caches still need somewhere persistent to live.</p>
<p>Historically, that usually pushed you toward Amazon EFS, or toward application-specific workarounds like uploading media directly to S3 through a plugin. Both approaches can work, but they each come with tradeoffs.</p>
<p>Recently, AWS introduced <strong>Amazon S3 Files</strong>, a feature that allows you to mount S3 buckets as local file systems. In this post, we'll dive into a demo project that wires this up using the AWS CDK, using WordPress as an example workload.</p>
<blockquote>
<p><strong>Tip:</strong> While this demo uses WordPress, the strategy applies to any application requiring persistent, shared storage, from content management systems to data processing pipelines.</p>
</blockquote>
<h2>Why this Matters</h2>
<p>Containers on Fargate are ephemeral by design. If a task is replaced, anything written to the container's local filesystem disappears. That's fine for APIs and workers that keep all state in external services. It's not fine for platforms that write important data to disk.</p>
<p>WordPress is notoriously stateful. While the database handles your posts and metadata, the filesystem handles:</p>
<ul>
<li><p><strong>Media Uploads:</strong> Images and videos stored in <code>wp-content/uploads</code>.</p>
</li>
<li><p><strong>Customizations:</strong> Plugins and themes installed via the admin dashboard.</p>
</li>
</ul>
<p>In a traditional Fargate deployment, these files are ephemeral. If your container restarts or scales out, your data disappears.</p>
<p>The exact same problem shows up in self-hosted headless CMS platforms like Strapi, internal admin tools, ML workflows that generate artifacts, and legacy applications that assume a shared writable directory exists.</p>
<h2>What Amazon S3 Files Changes</h2>
<p><a href="https://aws.amazon.com/s3/features/files">Amazon S3 Files</a> provides a middle ground between the massive scale of S3 and the POSIX-compliant interface required by apps like WordPress.</p>
<p>The "secret sauce" here is that <strong>S3 Files is built using Amazon EFS</strong>. It acts as an intelligent cache layer that loads your active working set onto high-performance EFS storage for low latency, while the authoritative source of truth remains your S3 bucket.</p>
<p>When you read a file, it's lazily loaded from S3 into this cache. When you write, the data is saved to the high-performance layer and then synchronized back to S3. This means you get the performance of a real filesystem with the durability and ecosystem of S3.</p>
<h2>The Architecture in this Demo</h2>
<p>Instead of redesigning the application to speak directly to S3, we mount storage into the container and keep the application model unchanged. The Fargate tasks mount S3 Files at <code>/bitnami/wordpress</code>, which is where the Bitnami WordPress image expects its data.</p>
<p>The demo project provisions a robust infrastructure through five CDK stacks:</p>
<ul>
<li><p><code>VPC</code>: A VPC with public, private, and isolated subnets. It uses <strong>VPC Endpoints</strong> (S3, ECR, Secrets Manager) instead of NAT Gateways to keep service-to-service traffic entirely within the AWS network.</p>
</li>
<li><p><code>Database</code>: A MariaDB instance managed by RDS, tucked away in isolated subnets with credentials in Secrets Manager.</p>
</li>
<li><p><code>S3Files</code>: An S3 bucket backed by an S3 Files file system, access point, and mount targets.</p>
</li>
<li><p><code>ECR</code>: A private ECR repository seeded with the <a href="https://gallery.ecr.aws/bitnami/wordpress">Bitnami WordPress Image</a>.</p>
</li>
<li><p><code>App</code>: An <code>ApplicationLoadBalancedFargateService</code> running two WordPress tasks mounted to <code>S3Files</code>.</p>
</li>
</ul>
<p>Here is how the stacks and resources connect to each other:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/66d69f2f-527d-497f-bc14-bba6803d56b4.png" alt="AWS ECS Fargate Architecture with Amazon S3 Files." style="display:block;margin:0 auto" />

<p>At the stack dependency level, <code>App</code> depends on <code>VPC</code>, <code>Database</code>, <code>S3Files</code>, and <code>ECR</code>. At runtime, the WordPress tasks pull the container image from ECR, read credentials from Secrets Manager, connect to MariaDB, and mount the S3 Files volume through the access point and mount targets.</p>
<p>The important part is the storage path:</p>
<ol>
<li><p>WordPress runs on ECS Fargate.</p>
</li>
<li><p>The task mounts an <code>S3 Files</code> volume.</p>
</li>
<li><p>That volume maps to an S3-backed file system.</p>
</li>
<li><p>The application writes to <code>/bitnami/wordpress</code> as if it were local persistent storage.</p>
</li>
</ol>
<p>This means uploads, plugins, themes, and similar filesystem state survive task replacement and can be shared across tasks.</p>
<blockquote>
<p><strong>Complete Source Code:</strong> You can find the full implementation, including all the CDK stacks, in the <a href="https://github.com/haZya/s3-files-demo-with-wp">GitHub Repository</a>.</p>
</blockquote>
<h2>Implementing with CDK</h2>
<p>Since S3 Files is a relatively new feature, the AWS CDK currently supports it primarily through <strong>L1 constructs</strong> (the low-level <code>Cfn</code> resources auto-generated from the CloudFormation schemas).</p>
<p>In our <code>S3FilesStack</code>, we define the <code>CfnFileSystem</code>, <code>CfnAccessPoint</code>, and <code>CfnMountTarget</code>. These are then wired into the ECS Task Definition in the <code>AppStack</code> using a bit of "escape hatch" patching to configure the <code>s3FilesVolumeConfiguration</code>.</p>
<p>The implementation breaks down into four practical steps.</p>
<h3>1. Create the Backing Bucket and S3 Files Resources</h3>
<p>In <code>lib/s3-files-stack.ts</code>, the stack creates a versioned S3 bucket and then wires that bucket into <code>aws-s3files</code> resources. We define the File System, the Access Point (defining how users/groups interact with the files):</p>
<pre><code class="language-ts">// From lib/s3-files-stack.ts
this.s3FilesBucket = new Bucket(this, 'AppFilesBucket', {
  versioned: true,
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

this.s3FilesFileSystem = new CfnS3FileSystem(this, 'S3FileSystem', {
  bucket: this.s3FilesBucket.bucketArn,
  roleArn: s3FilesBucketRole.roleArn,
  acceptBucketWarning: true,
});

this.s3FilesAccessPoint = new CfnS3AccessPoint(this, 'S3FilesAccessPoint', {
  fileSystemId: this.s3FilesFileSystem.attrFileSystemId,
  posixUser: { gid: '1001', uid: '1001' },
  rootDirectory: {
    path: '/wordpress',
    creationPermissions: {
      ownerGid: '1001',
      ownerUid: '1001',
      permissions: '755',
    },
  },
});
</code></pre>
<p>This is the heart of the pattern:</p>
<ul>
<li><p>The bucket is the durable storage layer.</p>
</li>
<li><p><code>CfnS3FileSystem</code> exposes that storage as an S3 Files file system.</p>
</li>
<li><p><code>CfnS3AccessPoint</code> defines how the application will enter that file tree.</p>
</li>
</ul>
<p>The access point is configured with UID/GID <code>1001</code> and a root directory of <code>/wordpress</code>, which lines up with the filesystem expectations of the Bitnami WordPress container.</p>
<h3>2. Create Mount Targets Inside the VPC</h3>
<p>The file system still needs network reachability from the ECS tasks. This creates mount targets in the <code>PRIVATE_WITH_EGRESS</code> subnets and opens NFS traffic on port <code>2049</code> from within the VPC:</p>
<pre><code class="language-ts">// From lib/s3-files-stack.ts
const mountTargetSecurityGroup = new SecurityGroup(this, 'S3FilesMountTargetSg', {
  description: 'S3 Files mount target SG',
  vpc,
});

mountTargetSecurityGroup.addIngressRule(
  Peer.ipv4(vpc.vpcCidrBlock),
  Port.tcp(2049),
  'Allow NFS from VPC',
);

const mountTargetSubnets = vpc
  .selectSubnets({ subnetType: SubnetType.PRIVATE_WITH_EGRESS, onePerAz: true })
  .subnets;

this.s3FilesMountTargets = mountTargetSubnets.map((subnet, index) =&gt; {
  return new CfnS3MountTarget(this, `S3FilesMountTarget${index + 1}`, {
    fileSystemId: this.s3FilesFileSystem.attrFileSystemId,
    subnetId: subnet.subnetId,
    securityGroups: [mountTargetSecurityGroup.securityGroupId],
  });
});
</code></pre>
<p>That is an important detail because this is not just an S3 bucket reference in ECS. The workload needs the network path to the mounted file system, so the mount target configuration matters.</p>
<h3>3. Give the Service and Tasks the Right Permissions (IAM)</h3>
<p>There are two sides to the permission model here:</p>
<p>First, <code>S3 Files</code> itself needs a specific service-linked role that lets the service synchronize data between the file system and the S3 bucket. In <code>lib/s3-files-stack.ts</code>, the role is assumed by <code>elasticfilesystem.amazonaws.com</code> and gets bucket, object, and EventBridge permissions:</p>
<pre><code class="language-typescript">// From lib/s3-files-stack.ts
const s3FilesBucketRole = new Role(this, 'S3FilesBucketRole', {
  assumedBy: new ServicePrincipal('elasticfilesystem.amazonaws.com', {
    conditions: {
      StringEquals: { 'aws:SourceAccount': this.account },
      ArnLike: { 'aws:SourceArn': `arn:aws:s3files:\({this.region}:\){this.account}:file-system/*` },
    },
  }),
});

s3FilesBucketRole.addToPolicy(new PolicyStatement({
  actions: ['s3:ListBucket', 's3:GetObject*', 's3:PutObject*', 's3:DeleteObject*'],
  resources: [bucket.bucketArn, `${bucket.bucketArn}/*`],
}));
</code></pre>
<p>Second, the ECS task role needs permission to actually use the mounted file system. In <code>lib/app-stack.ts</code>, the task gets the AWS managed policy <code>AmazonS3FilesClientReadWriteAccess</code> plus explicit S3 read/list access to the bucket:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
fargateService.taskDefinition.taskRole.addManagedPolicy(
  ManagedPolicy.fromAwsManagedPolicyName('AmazonS3FilesClientReadWriteAccess'),
);

fargateService.taskDefinition.taskRole.addToPrincipalPolicy(new PolicyStatement({
  sid: 'S3ObjectReadAccess',
  actions: ['s3:GetObject', 's3:GetObjectVersion'],
  resources: [`${bucketArn}/*`],
}));

fargateService.taskDefinition.taskRole.addToPrincipalPolicy(new PolicyStatement({
  sid: 'S3BucketListAccess',
  actions: ['s3:ListBucket'],
  resources: [bucketArn],
}));
</code></pre>
<p>This split is easy to miss when you first look at the feature. You need permissions for the service-side bucket integration and for the task-side runtime access.</p>
<h3>4. Patch the ECS Task Definition with <code>s3FilesVolumeConfiguration</code></h3>
<p>The ECS service itself is created with the familiar <code>ApplicationLoadBalancedFargateService</code> construct:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
const fargateService = new ApplicationLoadBalancedFargateService(this, 'AppService', {
  serviceName: 'WordpressDemoService',
  cluster,
  cpu: 256,
  memoryLimitMiB: 512,
  desiredCount: 2,
  publicLoadBalancer: true,
  taskImageOptions: {
    containerName: 'WordpressDemoContainer',
    family: 'WordpressDemoTask',
    image: ContainerImage.fromEcrRepository(ecrRepo, 'latest'),
    containerPort: 8080,
  },
  minHealthyPercent: 100,
});
</code></pre>
<p>Then the default container gets a mount point:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
const volumeName = 's3files';
const mountPath = '/bitnami/wordpress';  // App's data path

fargateService.taskDefinition.defaultContainer?.addMountPoints({
  containerPath: mountPath,
  sourceVolume: volumeName,
  readOnly: false,
});
</code></pre>
<p>And finally, since high-level L2 construct support is still evolving for this feature, we use a CDK <strong>Escape Hatch</strong>. This allows us to "drop down" to the underlying CloudFormation resource (<code>CfnTaskDefinition</code>) and manually configure the <code>s3FilesVolumeConfiguration</code> property:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
const cfnTaskDefinition = fargateService.taskDefinition.node.defaultChild as CfnTaskDefinition;
const existingVolumes = Array.isArray(cfnTaskDefinition.volumes)
  ? cfnTaskDefinition.volumes
  : [];

cfnTaskDefinition.volumes = [
  ...existingVolumes,
  {
    name: volumeName,
    s3FilesVolumeConfiguration: {
      accessPointArn: s3FilesAccessPoint.attrAccessPointArn,
      fileSystemArn: s3FilesFileSystem.attrFileSystemArn,
      rootDirectory: '/',
    },
  },
];
</code></pre>
<blockquote>
<p><strong>Note:</strong> Using Escape Hatches is a standard practice in CDK when you need to use a new AWS feature before the high-level constructs have been updated to support it.</p>
</blockquote>
<h2>End-to-End Request Flow</h2>
<p>Once deployed, the runtime model is straightforward:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/532a58b8-70d6-47c3-b235-4170113aefd5.png" alt="End-to-end request flow for WordPress App on AWS ECS Fargate with Amazon S3 Files." style="display:block;margin:0 auto" />

<ol>
<li><p>A request hits the Application Load Balancer.</p>
</li>
<li><p>One of the Fargate WordPress tasks handles it.</p>
</li>
<li><p>WordPress reads or writes persistent content under <code>/bitnami/wordpress</code>.</p>
</li>
<li><p>That path is backed by the S3 Files volume.</p>
</li>
<li><p>The durable backing store for that content is the S3 bucket from the <code>S3Files</code> stack.</p>
</li>
</ol>
<p>That means if a task is replaced or if the service scales horizontally, the content directory is still shared and persistent.</p>
<h2>Why the Mount Path Matters</h2>
<p>One subtle but important detail in this demo is that the S3 Files volume is mounted at <code>/bitnami/wordpress</code>, not at some arbitrary side directory.</p>
<p>That is what keeps the application model simple. The container image already expects its writable application data there, so the infrastructure is adapting to the app rather than forcing the app to adapt to the infrastructure.</p>
<p>That is a big part of why this same pattern can apply to other stateful applications. For example, in the case of Strapi, the mount path would be <code>/public/uploads</code>. If you can identify the directory that the application treats as its durable shared state, you can often mount <code>S3 Files</code> there and avoid more invasive application changes.</p>
<h2>Why this is Attractive for Stateful Applications</h2>
<p>What I like about this approach is that it keeps the application architecture simple.</p>
<p>You do not need to teach WordPress how to store media in S3. You do not need to redesign the app around object storage APIs. And you do not need to split "database state" from "filesystem state" in an application-specific way just to make containers viable.</p>
<p>Instead, you can preserve the existing runtime assumptions:</p>
<ul>
<li><p>The app writes files.</p>
</li>
<li><p>Multiple tasks can share those files.</p>
</li>
<li><p>The data survives task churn.</p>
</li>
<li><p>The durable backing store is S3.</p>
</li>
</ul>
<p>That last point is especially appealing because S3 has operational advantages people already know how to use: lifecycle policies, replication strategies, inventory, access controls, and long-term storage economics.</p>
<blockquote>
<p><strong>Practical Note:</strong> Because S3 Files uses an EFS-backed cache, writes are available to other tasks almost instantly. However, the background synchronization to the S3 bucket itself can take 30 to 60 seconds. If you're checking the S3 console for your files immediately after an upload, don't panic if they don't show up right away!</p>
</blockquote>
<h2>How Does It Compare?</h2>
<p>When deciding how to handle WordPress storage, you generally have three paths:</p>
<h3>1. Amazon S3 Files (The New Way)</h3>
<ul>
<li><p><strong>Pros:</strong> Near-infinite storage of S3, lower cost than high-performance EFS tiers, and easier data management (it's just a bucket!).</p>
</li>
<li><p><strong>Cons:</strong> Currently requires lower-level configuration (L1 constructs) in CDK.</p>
</li>
</ul>
<h3>2. Amazon EFS (The Traditional Way)</h3>
<ul>
<li><p><strong>Pros:</strong> Mature, fully POSIX-compliant, and has excellent L2 construct support in CDK.</p>
</li>
<li><p><strong>Cons:</strong> More expensive than S3 for large amounts of "cold" media files, and managing EFS lifecycle policies can be more complex than S3.</p>
</li>
</ul>
<h3>3. "Offload Media" Plugins</h3>
<ul>
<li><p><strong>Pros:</strong> No complex infrastructure needed; plugins like <em>WP Offload Media</em> sync your <code>uploads</code> folder directly to S3 and rewrite URLs.</p>
</li>
<li><p><strong>Cons:</strong> Usually only handles media. Plugins and themes still need a persistent home, and these plugins often have a premium cost or require extra configuration within WordPress itself.</p>
</li>
</ul>
<h3>The "S3 Files" Advantage</h3>
<p>S3 Files is particularly powerful because it treats your bucket as the source of truth. You can use standard S3 features like <strong>Lifecycle Policies</strong>, <strong>Replication</strong>, and <strong>Inventory</strong> while your application thinks it's just writing to a local disk.</p>
<p>This makes it an ideal fit for:</p>
<ul>
<li><p><strong>Legacy Migrations:</strong> Apps that expect a filesystem, but you want to store data in S3.</p>
</li>
<li><p><strong>Shared Assets:</strong> Multiple containers needing access to a common set of images, logs, or configurations.</p>
</li>
<li><p><strong>Cost Management:</strong> Leveraging S3's low-cost storage classes for large datasets.</p>
</li>
</ul>
<h2>The Future</h2>
<p>Using L1 constructs today feels a bit like "coding close to the metal," but it gives us access to this powerful feature right now. As the feature matures, we can expect the AWS CDK team to release <strong>L2 constructs</strong> that will make this integration much simpler.</p>
<h2>Taking it to Production</h2>
<p>While this demo gives you a solid foundation, there are a few "Day 2" enhancements you'll want to consider before going live:</p>
<ul>
<li><p><strong>CloudFront &amp; WAF:</strong> Place a CloudFront distribution in front of your ALB to cache static assets (like images from S3) at edge locations. This reduces the load on your Fargate tasks and saves you money on data transfer. Don't forget to attach <strong>AWS WAF</strong> to block SQL injection and cross-site scripting (XSS) attacks.</p>
</li>
<li><p><strong>Database High Availability:</strong> In this demo, we use a single MariaDB instance. For production, you should enable <strong>Multi-AZ</strong> for RDS to ensure your database can failover automatically if an Availability Zone goes down.</p>
</li>
<li><p><strong>Backup &amp; Recovery:</strong> While S3 is highly durable, you should still use <strong>AWS Backup</strong> or S3 Versioning to protect against accidental deletions or application-level corruption.</p>
</li>
<li><p><strong>Auto-scaling:</strong> Configure your Fargate service to scale the number of tasks automatically based on CPU or memory usage. This ensures your site stays responsive during traffic spikes without over-provisioning.</p>
</li>
<li><p><strong>Monitoring:</strong> Set up <strong>CloudWatch Alarms</strong> for your ALB's 5XX errors and RDS CPU utilization, so you're the first to know if something goes wrong.</p>
</li>
</ul>
<h2>Conclusion</h2>
<p>Amazon S3 Files represents a significant step forward in simplifying stateful container architecture. By combining the infinite scale of S3 with the accessibility of a file system, it provides a flexible, cost-effective solution for any stateful workload on AWS, including Lambda, EC2, ECS, EKS, Fargate, and Batch.</p>
<p>If you are working with a CMS, an internal platform, or any application that expects shared writable files, this is one of the most promising new AWS features to experiment with.</p>
<p>Check out the full source code for this demo project <a href="https://github.com/haZya/s3-files-demo-with-wp">here</a> and start modernizing your stateful apps today!</p>
]]></content:encoded></item><item><title><![CDATA[Deep Dive: Building a Secure, Event-Driven File Processing Pipeline with AWS CDK]]></title><description><![CDATA[When people say "file upload," they often mean a simple PUT to S3 and a database row. In a hobby project, that's fine. In production, it’s a liability.
Production-grade file processing requires answer]]></description><link>https://blog.hazya.dev/deep-dive-building-a-secure-event-driven-file-processing-pipeline-with-aws-cdk</link><guid isPermaLink="true">https://blog.hazya.dev/deep-dive-building-a-secure-event-driven-file-processing-pipeline-with-aws-cdk</guid><category><![CDATA[aws-cdk]]></category><category><![CDATA[cloud architecture]]></category><category><![CDATA[stepfunction]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[event-driven-architecture]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Fri, 03 Apr 2026 14:10:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/752f7f7b-7006-446e-8457-c0e571ac9c21.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When people say "file upload," they often mean a simple <code>PUT</code> to S3 and a database row. In a hobby project, that's fine. In production, it’s a liability.</p>
<p>Production-grade file processing requires answering a much harder set of questions:</p>
<ul>
<li><p>How do I isolate untrusted files from my clean assets?</p>
</li>
<li><p>Where does malware scanning fit without blocking the user?</p>
</li>
<li><p>How do I validate file types beyond the easily spoofed browser MIME type?</p>
</li>
<li><p>How do I fan out status updates to users and other services?</p>
</li>
<li><p>How do I replace old files safely without leaking orphaned objects?</p>
</li>
<li><p>How do I keep the whole system event-driven instead of building a tightly coupled "upload monolith"?</p>
</li>
</ul>
<p>In this article, we’ll walk through a self-contained file-processing microservice built with <strong>AWS CDK and TypeScript</strong>. We leverage S3, EventBridge, GuardDuty Malware Protection, Step Functions, and API Gateway WebSockets to build a pipeline that handles everything from ingestion to real-time status delivery.</p>
<blockquote>
<p><strong>Complete Source Code:</strong> You can find the full implementation, including all Lambda handlers and CDK stacks, in the <a href="https://github.com/haZya/upload-file-processing-pipeline">GitHub Repository</a>.</p>
</blockquote>
<h2>What We Are Building</h2>
<p>At a high level, the service follows a strict security-first workflow:</p>
<ol>
<li><p><strong>Ingestion:</strong> A client requests a presigned POST and uploads a file to a <strong>Staging Bucket</strong>.</p>
</li>
<li><p><strong>Registration:</strong> An S3 event triggers a Lambda to record the upload as <code>PENDING_SCAN</code> in DynamoDB.</p>
</li>
<li><p><strong>Security Gate:</strong> <strong>AWS GuardDuty Malware Protection</strong> scans the object asynchronously.</p>
</li>
<li><p><strong>Orchestration:</strong> A GuardDuty scan result event triggers an <strong>AWS Step Functions Express Workflow</strong>.</p>
</li>
<li><p><strong>Processing:</strong> The workflow validates the file signature, moves it to the <strong>Clean Bucket</strong>, transforms images (using <code>sharp</code>), updates metadata, and cleans up old files.</p>
</li>
<li><p><strong>Real-time Notify:</strong> Status changes are fanned out via <strong>EventBridge</strong> to a <strong>WebSocket API</strong> for immediate client feedback.</p>
</li>
</ol>
<p>This design ensures a clean separation between ingestion, security, orchestration, and real-time communication.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/a3dfdac1-f4ec-42d2-8e5b-6467a335fd96.png" alt="Step Functions Graph." style="display:block;margin:0 auto" />

<h2>Architecture Overview</h2>
<p>The repository is organized into five modular CDK stacks, keeping domain concerns isolated:</p>
<ul>
<li><p><code>lib/storage-stack.ts</code> - Private S3 buckets + CloudFront distribution for serving assets.</p>
</li>
<li><p><code>lib/database-stack.ts</code> - DynamoDB tables for uploads and relation tracking.</p>
</li>
<li><p><code>lib/guard-duty-stack.ts</code> - GuardDuty Malware Protection for the staging bucket.</p>
</li>
<li><p><code>lib/upload-processing-stack.ts</code> - The orchestration core: EventBridge, Step Functions, and Lambdas.</p>
</li>
<li><p><code>lib/websocket-stack.ts</code> - API Gateway WebSocket API and connection tracking.</p>
</li>
</ul>
<p>The app wiring in <code>bin/app.ts</code> is intentionally simple, demonstrating the power of stack composition:</p>
<pre><code class="language-ts">const storageStack = new StorageStack(app, "Storage");
const databaseStack = new DatabaseStack(app, "Database");
const guardDutyStack = new GuardDutyStack(app, "GuardDuty", {
  stagingUploadBucket: storageStack.stagingUploadBucket,
});
const webSocketStack = new WebSocketStack(app, "WebSocket", {});

const uploadProcessingStack = new UploadProcessingStack(app, "UploadProcessing", {
  stagingUploadBucket: storageStack.stagingUploadBucket,
  uploadBucket: storageStack.uploadBucket,
  uploadsTable: databaseStack.uploadsTable,
  uploadRelationsTable: databaseStack.uploadRelationsTable,
  webSocket: webSocketStack,
});
</code></pre>
<h2>Why the Dual-bucket Strategy Matters</h2>
<p>The first important design choice is the storage layer. Instead of uploading directly into your final bucket, this service uses two buckets: a <strong>staging bucket</strong> for untrusted files and an <strong>upload bucket</strong> for processed, clean assets. This "DMZ" approach prevents your final storage from ever becoming a dumping ground for unscanned content.</p>
<p>Here is the essence of <code>lib/storage-stack.ts</code>:</p>
<pre><code class="language-ts">this.stagingUploadBucket = new Bucket(this, "StagingUploadBucket", {
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  enforceSSL: true,
  minimumTLSVersion: 1.2,
  eventBridgeEnabled: true,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
  lifecycleRules: [
    {
      abortIncompleteMultipartUploadAfter: Duration.days(1),
      expiration: Duration.days(7),
    },
  ],
});

this.uploadBucket = new Bucket(this, "UploadBucket", {
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  enforceSSL: true,
  minimumTLSVersion: 1.2,
});

const cachePolicy = new CachePolicy(this, "UploadBucketCachePolicy", {
    defaultTtl: Duration.days(7),
    minTtl: Duration.seconds(0),
    maxTtl: Duration.days(30),
});

new Distribution(this, "UploadBucketDistribution", {
  defaultBehavior: {
    origin: S3BucketOrigin.withOriginAccessControl(this.uploadBucket),
    viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    cachePolicy,
  },
  minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_3_2025,
});
</code></pre>
<p>There are a few good ideas packed into this:</p>
<ul>
<li><p>The staging bucket is private and event-enabled.</p>
</li>
<li><p>Incomplete multipart uploads are cleaned up automatically.</p>
</li>
<li><p>The clean upload bucket is also private.</p>
</li>
<li><p>CloudFront uses Origin Access Control, so S3 stays off the public internet path.</p>
</li>
</ul>
<blockquote>
<p><strong>Pro Tip:</strong> While this setup enforces TLS and private access, you should consider adding <code>BucketEncryption.KMS</code> for defense-in-depth and granular auditing in production environments.</p>
</blockquote>
<p>A production-oriented version with a KMS key could look more like this:</p>
<pre><code class="language-ts">const key = new kms.Key(this, "UploadsKey", {
  enableKeyRotation: true,
});

const uploadBucket = new Bucket(this, "UploadBucket", {
  encryption: BucketEncryption.KMS,
  encryptionKey: key,
  bucketKeyEnabled: true,
  // ...Other options
});
</code></pre>
<h2>Modeling Uploads in DynamoDB</h2>
<p>The database layer splits responsibilities across two tables to handle different access patterns:</p>
<ul>
<li><p><code>UploadsTable</code> stores every upload event (the historical record).</p>
</li>
<li><p><code>UploadRelationsTable</code> tracks the current file for a specific business entity (e.g., "user:123:avatar").</p>
</li>
</ul>
<p><code>lib/database-stack.ts</code> defines two useful GSIs on <code>UploadsTable</code>:</p>
<ul>
<li><p><code>ByRelation</code> for querying uploads by logical owner or relation.</p>
</li>
<li><p><code>ByStagingKey</code> for resolving the latest record tied to a staging object key.</p>
</li>
</ul>
<pre><code class="language-ts">this.uploadsTable = new TableV2(this, "UploadsTable", {
  partitionKey: { name: "uploadId", type: AttributeType.STRING },
  pointInTimeRecoverySpecification: {
    pointInTimeRecoveryEnabled: true,
  },
  globalSecondaryIndexes: [
    {
      indexName: "ByRelation",
      partitionKey: { name: "relationKey", type: AttributeType.STRING },
      sortKey: { name: "createdAt", type: AttributeType.STRING },
    },
    {
      indexName: "ByStagingKey",
      partitionKey: { name: "stagingKey", type: AttributeType.STRING },
      sortKey: { name: "createdAt", type: AttributeType.STRING },
    },
  ],
});

this.uploadRelationsTable = new TableV2(this, "UploadRelationsTable", {
    partitionKey: { name: "relationKey", type: AttributeType.STRING },
    removalPolicy: RemovalPolicy.DESTROY,
    pointInTimeRecoverySpecification: {
        pointInTimeRecoveryEnabled: true,
    },
});
</code></pre>
<p>This separation allows you to query "what happened to upload X" while simultaneously allowing the UI to instantly find the "current hero image" for a product.</p>
<p>The <code>lambda/upload/update-status.ts</code> handler uses that second table to atomically move a relation to the newest successful upload while keeping a reference to the previous one for cleanup.</p>
<h2>Ingestion: Generating the Presigned Upload</h2>
<p>The upload path starts in <code>lambda/upload/generate-presigned-post.ts</code>. A key detail here is binding metadata directly into the presigned POST conditions. By forcing <code>x-amz-meta-author-id</code> and <code>x-amz-meta-relation-key</code> into the upload itself, we ensure downstream processors never have to "guess" the context.</p>
<pre><code class="language-ts">const { url, fields } = await createPresignedPost(s3, {
  Bucket: stagingBucket,
  Key: key,
  Conditions: [
    ["content-length-range", 0, 100 * 1024 * 1024],
    ["starts-with", "$Content-Type", contentType ?? ""],
    ["eq", "$x-amz-meta-relation-key", relationKey],
    ["eq", "$x-amz-meta-author-id", userId],
  ],
  Fields: {
    ...(contentType ? { "Content-Type": contentType } : {}),
    "x-amz-meta-relation-key": relationKey,
    "x-amz-meta-author-id": userId,
  },
  Expires: 600,
});
</code></pre>
<h2>Security First: GuardDuty Malware Protection for S3</h2>
<p>We use <strong>GuardDuty Malware Protection for S3</strong> as our security gate. It scans objects asynchronously and emits EventBridge events, which is far more efficient and scalable than writing custom antivirus logic in Lambda.</p>
<p>From <code>lib/guard-duty-stack.ts</code>:</p>
<pre><code class="language-ts">const plan = new CfnMalwareProtectionPlan(this, "S3MalwareProtectionPlan", {
  role: role.roleArn,
  protectedResource: {
    s3Bucket: {
      bucketName: stagingUploadBucket.bucketName,
    },
  },
  actions: {
    tagging: { status: "ENABLED" },
  },
});
</code></pre>
<p>The stack also grants the GuardDuty service role the minimum S3, EventBridge, and optional KMS permissions it needs.</p>
<p>Why this design works well:</p>
<ul>
<li><p>S3 remains the system of record for raw files.</p>
</li>
<li><p>GuardDuty performs the malware check asynchronously.</p>
</li>
<li><p>The result shows up as an EventBridge event.</p>
</li>
<li><p>Object tagging can record scan outcomes such as <code>NO_THREATS_FOUND</code> and <code>THREATS_FOUND</code>.</p>
</li>
</ul>
<h2>EventBridge as the Backbone</h2>
<p>The service uses EventBridge in two different ways.</p>
<p>First, AWS service events kick off work:</p>
<ul>
<li><p>S3 <code>Object Created</code> events register the upload as pending.</p>
</li>
<li><p>GuardDuty malware scan result events start the Step Functions workflow.</p>
</li>
</ul>
<p>Second, the service emits its own internal domain events on a dedicated bus: <code>UploadStatusChanged</code>.</p>
<p>That gives the system a very clean shape: inbound events drive orchestration, and outbound events describe state changes for consumers.</p>
<pre><code class="language-ts">new Rule(this, "S3ObjectCreatedRule", {
  eventPattern: {
    source: ["aws.s3"],
    detailType: ["Object Created"],
    detail: {
      bucket: { name: [stagingUploadBucket.bucketName] },
    },
  },
  targets: [
    new LambdaFunction(registerUploadLambda, {
      event: RuleTargetInput.fromObject({
        bucket: EventField.fromPath("$.detail.bucket.name"),
        key: EventField.fromPath("$.detail.object.key"),
      }),
      retryAttempts: 4,
      deadLetterQueue: s3ObjectCreatedDeliveryDlq,
    }),
  ],
});
</code></pre>
<p>The <code>lambda/upload/register-upload.ts</code> handler calls <code>HeadObject</code>, reads metadata, and creates a DynamoDB row with a <code>PENDING_SCAN</code> status.</p>
<h2>Orchestration: Step Functions Express Workflow</h2>
<p>This is the "brain" of the system. The actual architectural center of the repo, which manages the complex branching logic of the pipeline:</p>
<p><strong>Security Check:</strong> If GuardDuty finds a threat, delete the file and fail. <strong>Deep Validation:</strong> Use file-type to inspect the "magic numbers" of the file, ignoring spoofed MIME headers. <strong>Parallel Processing:</strong> Transform images (using <code>sharp</code>) and delete the staging file simultaneously.</p>
<p>The workflow is triggered by GuardDuty scan results. If the object is clean, the pipeline validates the file, resolves the final key, copies the object to the clean bucket, optionally generates image variants, stores metadata, updates the upload record, emits a status event, and cleans up a previously replaced upload if needed.</p>
<p>If the object is malicious or invalid, it deletes the staging object and marks the upload as failed.</p>
<h3>Why Express Workflow?</h3>
<p>We use Express Workflows with <code>StateMachineType.EXPRESS</code> for this orchestration because file processing is a high-volume, short-lived task where latency and cost-efficiency are paramount. Having said that, express workflows do not support <a href="https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html">long-running callback patterns</a> like <code>.waitForTaskToken</code> or <code>.sync</code>. If you need to wait for external human approval (HITL) or a long asynchronous job, switch to Standard.</p>
<h3>The Key Tasks in the Workflow</h3>
<p><code>lib/upload-processing-stack.ts</code> wires several Lambdas into the state machine:</p>
<ul>
<li><p><code>validate.ts</code> - checks file signature using <code>file-type</code>.</p>
</li>
<li><p><code>resolve-final-key.ts</code> - generates the durable object key.</p>
</li>
<li><p><code>transform-image.ts</code> - creates WebP derivatives with <code>sharp</code>.</p>
</li>
<li><p><code>add-metadata.ts</code> - persists final key, dimensions, and formats.</p>
</li>
<li><p><code>update-status.ts</code> - marks the upload successful or failed and updates the relation state.</p>
</li>
<li><p><code>cleanup-replaced-upload.ts</code> - deletes the previous file version if the relation now points elsewhere.</p>
</li>
</ul>
<p>It also uses direct AWS SDK integrations for S3 copy and delete operations, which keeps the workflow explicit and avoids extra Lambda glue.</p>
<h3>The Workflow Definition</h3>
<p>This is the part many teams hand-wave. This demo does not. The state machine is modeled directly in CDK:</p>
<pre><code class="language-ts">const coreWorkflow = new Choice(this, "ScanResultOK")
  .when(
    Condition.stringEquals("$.scanResultStatus", "NO_THREATS_FOUND"),
    validateFileTask.next(
      new Choice(this, "IsFileValid")
        .when(
          Condition.booleanEquals("$.isValid", true),
          resolveFinalKeyTask
            .next(copyToUploadBucketTask)
            .next(transformAndDelete),
        )
        .otherwise(deleteInvalidStagingObjectTask.next(markValidationFailedTask)),
    ),
  )
  .otherwise(deleteThreatStagingObjectTask.next(markThreatDetectedTask));

const definition = new Parallel(this, "MainWorkflowGroup", {
  outputPath: "$.[0]",
})
  .branch(coreWorkflow)
  .addCatch(
    updateUploadStatusFailureTask
      .next(uploadStatusEmitFailureOnCatch)
      .next(workflowFailState),
    {
      errors: [Errors.ALL],
      resultPath: "$.error",
    },
  )
  .next(postProcessingFlow);

const stateMachine = new StateMachine(this, "FileUploadStateMachine", {
  definitionBody: DefinitionBody.fromChainable(definition),
  timeout: Duration.minutes(5),
  stateMachineType: StateMachineType.EXPRESS,
  logs: {
    destination: logGroup,
    level: LogLevel.ALL,
    includeExecutionData: true,
  },
});
</code></pre>
<p>There are a few design choices here worth highlighting.</p>
<p>First, the workflow branches early on the security outcome. That means a malware-positive file never reaches the rest of the processing path.</p>
<p>Second, image transformation and staging-object deletion run inside a <code>Parallel</code> state:</p>
<pre><code class="language-ts">const transformAndDelete = new Parallel(this, "TransformAndDeleteStagingFile", {
  outputPath: "$.[0]",
});

transformAndDelete.branch(
  new Choice(this, "IsImage")
    .when(Condition.stringMatches("$.mime", "image/*"), transformImageTask)
    .otherwise(new Pass(this, "SkipTransformForNonImage"))
    .afterwards()
    .next(addMetadataTask),
);

transformAndDelete.branch(deleteStagingObjectOnCopySuccessTask);
</code></pre>
<p>That is a nice example of Step Functions as a real orchestration engine, not just a fancy switch statement.</p>
<p>Third, the stack centralizes retry policies for Lambda, S3, and EventBridge tasks:</p>
<pre><code class="language-ts">private addServiceRetry(task: CallAwsService | LambdaInvoke | EventBridgePutEvents, errors: string[]) {
  task.addRetry({
    errors,
    interval: Duration.seconds(2),
    backoffRate: 2,
    maxAttempts: 3,
  });
}
</code></pre>
<p>That small helper keeps resilience consistent across the workflow.</p>
<h2>File Validation Beyond the Content-Type Header</h2>
<p>One of the easiest upload mistakes is trusting the browser-provided MIME type. This demo does better. The <code>lambda/upload/validate.ts</code> reads the first few kilobytes of the object and uses <code>file-type</code> to inspect the file signature:</p>
<pre><code class="language-ts">const object = await s3.send(new GetObjectCommand({
  Bucket: bucket,
  Key: key,
  Range: "bytes=0-4095",
}));

const buffer = await object.Body.transformToByteArray();
const detected = await fileTypeFromBuffer(buffer);
const detectedMime = detected?.mime ?? null;
const mime = detectedMime ?? head.ContentType ?? "application/octet-stream";
const isValid = !(detectedMime &amp;&amp; head.ContentType &amp;&amp; detectedMime !== head.ContentType);
</code></pre>
<p>That is still lightweight, but it already closes a very common trust gap. If the content is invalid, the workflow deletes the staging object and marks the upload as <code>VALIDATION_FAILED</code>.</p>
<h2>Image Transformation and Derivative Generation</h2>
<p>If the file is an image, the workflow creates optimized WebP variants with <code>sharp</code>.</p>
<p>From <code>lambda/upload/transform-image.ts</code>:</p>
<pre><code class="language-ts">async function createVariant(width: number, prefix: string) {
  const transformed = await sharp(buffer)
    .resize({ width, withoutEnlargement: true })
    .webp({ quality: 75 })
    .toBuffer({ resolveWithObject: true });

  const parts = event.finalKey.split("/");
  const fileName = parts.pop()!;
  const newKey = [...parts, `\({prefix}-\){fileName}`].join("/");

  await s3.send(new PutObjectCommand({
    Bucket: uploadBucket,
    Key: newKey,
    Body: transformed.data,
    ContentType: "image/webp",
  }));

  return {
    key: newKey,
    width: transformed.info.width,
    height: transformed.info.height,
    size: transformed.info.size,
    mime: "image/webp",
  };
}
</code></pre>
<p>The handler returns the original dimensions plus a <code>formats</code> array, and <code>add-metadata.ts</code> persists that back into DynamoDB. That means the final upload record is not just "file uploaded"; it is an asset descriptor that a frontend can actually use.</p>
<h3>The <code>sharp</code> Challenge</h3>
<p>Bundling native modules like <code>sharp</code> for Lambda can be tricky. We use <code>NodejsFunction</code> with Docker-based bundling to ensure the binary is compiled for the Lambda Linux environment:</p>
<pre><code class="language-ts">bundling: {
  nodeModules: ["sharp"],
  forceDockerBundling: true,
  environment: {
    NPM_CONFIG_BIN_LINKS: "false",
  },
}
</code></pre>
<blockquote>
<p><strong>Note:</strong> If you're on Windows, you may have to set <code>NPM_CONFIG_BIN_LINKS</code> environment variable to <code>false</code> to disable symlink creation.</p>
</blockquote>
<h2>Relation-aware Status Tracking and Atomic Replacement</h2>
<p>One of the more sophisticated patterns in this architecture is the decoupling of <strong>Uploads</strong> from <strong>Relations</strong>.</p>
<ul>
<li><p><strong>An Upload</strong> is an immutable historical record. It tracks the journey of a specific file from staging to final storage, including its metadata and processing results.</p>
</li>
<li><p><strong>A Relation</strong> is a logical pointer within your application domain (e.g., <code>user:42:avatar</code> or <code>product:99:hero-image</code>) that resolves to a specific, successful upload.</p>
</li>
</ul>
<h3>Why This Decoupling Matters</h3>
<p>In many systems, updating a file means overwriting the existing object or manually deleting the old one before uploading the new one. This often leads to race conditions, orphaned files, or broken UI states.</p>
<p>By using a dedicated <code>UploadRelationsTable</code>, we implement a <strong>fail-safe replacement strategy</strong>:</p>
<ol>
<li><p><strong>Late Binding:</strong> The application relation only updates <em>after</em> the entire processing pipeline succeeds.</p>
</li>
<li><p><strong>Reference Tracking:</strong> When <code>lambda/upload/update-status.ts</code> marks a new upload as complete, it updates the relation to point to the new asset while capturing the <code>previousUploadId</code>.</p>
</li>
<li><p><strong>Automatic Cleanup:</strong> This hand-off allows the <code>lambda/upload/cleanup-replaced-upload.ts</code> task to safely delete the old file and all its generated variants (WebP, thumbnails, etc.) without impacting the live application.</p>
</li>
</ol>
<p>This approach ensures that your application always points to a valid, scanned asset, and your storage never becomes a graveyard of abandoned "Version 1" files. It provides the benefits of object versioning without the complexity of S3 Bucket Versioning for your application logic.</p>
<h2>Real-time Communication with WebSockets</h2>
<p>The last mile is status delivery. Because the pipeline is asynchronous, the user needs immediate feedback. The service emits <code>UploadStatusChanged</code> events to an internal EventBridge bus. The <code>lambda/upload/emit-upload-status.ts</code> subscriber Lambda then looks up active connections in DynamoDB and pushes the update via <strong>API Gateway WebSockets</strong>.</p>
<pre><code class="language-ts">private createUploadStatusEmitTask(id: string, uploadStatusBus: EventBus) {
  return new EventBridgePutEvents(this, id, {
    entries: [
      {
        eventBus: uploadStatusBus,
        detailType: "UploadStatusChanged",
        detail: TaskInput.fromJsonPathAt("$"),
        source: "com.file-processing.upload",
      },
    ],
    resultPath: "$.eventBridgeResult",
  });
}
</code></pre>
<p>The WebSocket stack stores connections in DynamoDB with a TTL so stale sessions age out automatically.</p>
<p>From <code>lib/websocket-stack.ts</code>:</p>
<pre><code class="language-ts">const connectionsTable = new TableV2(this, "WebSocketConnectionsTable", {
  partitionKey: { name: "PK", type: AttributeType.STRING },
  sortKey: { name: "SK", type: AttributeType.STRING },
  timeToLiveAttribute: "ttl",
  pointInTimeRecoverySpecification: {
    pointInTimeRecoveryEnabled: true,
  },
  dynamoStream: StreamViewType.NEW_AND_OLD_IMAGES,
});
</code></pre>
<p>And the connection handler refreshes TTL on inbound messages to keep live sockets active.</p>
<blockquote>
<p><strong>Note:</strong> The <code>lambda/websocket/authorizer.ts</code> in the demo is explicitly mocked to return a hard-coded demo subject after "verification". For a real deployment, replace that with actual JWT verification against Cognito or your identity provider.</p>
</blockquote>
<h2>End-to-End Flow</h2>
<p>Here is the mental model I would use when explaining the pipeline:</p>
<pre><code class="language-text">Client
  -&gt; Request presigned POST
  -&gt; Upload file to staging bucket

S3 Object Created
  -&gt; EventBridge rule
  -&gt; Register-upload Lambda
  -&gt; DynamoDB status = PENDING_SCAN

GuardDuty scans staging object
  -&gt; EventBridge scan result
  -&gt; Step Functions Express workflow

If clean
  -&gt; Validate file signature
  -&gt; Resolve final key
  -&gt; Copy to clean bucket
  -&gt; Optionally transform image
  -&gt; Save metadata
  -&gt; Mark upload complete
  -&gt; Clean previous version
  -&gt; Emit UploadStatusChanged

If invalid or malicious
  -&gt; Delete staging object
  -&gt; Mark failed status
  -&gt; Emit UploadStatusChanged

Internal event bus
  -&gt; WebSocket notifier Lambda
  -&gt; Connected clients receive status update
</code></pre>
<h3>Step Functions Graph for a Successful File Upload.</h3>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/9fd624b7-8f1a-4058-81f0-9a3703420688.png" alt="Step Functions success graph." style="display:block;margin:0 auto" />

<h3>WebSocket Notification for a Successful File Upload</h3>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/777da2fb-6f3e-4545-8a1f-166b510b7203.png" alt="WebSocket notification for success." style="display:block;margin:0 auto" />

<h2>Architectural Benefits</h2>
<p>The strength of this architecture lies in its strict adherence to the <strong>Single Responsibility Principle (SRP)</strong> at the infrastructure level. Rather than building a monolithic "Upload Lambda," we've distributed concerns across managed services that are natively optimized for their respective tasks.</p>
<ul>
<li><p><strong>Decoupled Storage (S3):</strong> S3 is used strictly for what it does best—durable, scalable object storage—rather than being cluttered with orchestration logic.</p>
</li>
<li><p><strong>Security as a Service (GuardDuty):</strong> By offloading malware scanning to GuardDuty, we eliminate the operational burden and scaling limits of maintaining custom antivirus signatures in Lambda.</p>
</li>
<li><p><strong>Explicit Orchestration (Step Functions):</strong> Complex branching, retries, and parallel execution are managed in a visual state machine, making the workflow observable and easy to modify without touching code.</p>
</li>
<li><p><strong>State vs. Event (DynamoDB &amp; EventBridge):</strong> DynamoDB maintains the source of truth for asset metadata, while EventBridge acts as the nervous system, routing state changes to downstream consumers without tight coupling.</p>
</li>
<li><p><strong>Asynchronous Communication (WebSockets):</strong> Real-time feedback is treated as a reactive side-effect of domain events, not a synchronous dependency of the processing pipeline.</p>
</li>
</ul>
<p>This modularity ensures that the service is not just easy to build, but easy to evolve. You can swap the image processor, add new security gates, or change how notifications are delivered without ever disrupting the core ingestion path.</p>
<h2>Scaling to Production: Roadmap and Optimizations</h2>
<p>While this architecture provides a robust foundation, transitioning to a high-scale production environment often requires additional optimizations for efficiency, cost, and observability.</p>
<h3>1. Extending the Processing Pipeline</h3>
<p>The Step Functions backbone makes it trivial to insert new processing stages. As your application grows, you can easily integrate:</p>
<ul>
<li><p><strong>AI/ML Insights:</strong> Add Amazon Rekognition for moderation or Textract for document analysis.</p>
</li>
<li><p><strong>Frontend Optimization:</strong> Generate <strong>Blurhash</strong> values for instant placeholders or low-resolution image previews.</p>
</li>
<li><p><strong>Rich Metadata:</strong> Extract EXIF data, strip sensitive location tags, or generate multi-format derivatives (AVIF, WebP, PDF).</p>
</li>
<li><p><strong>Compliance:</strong> Implement automated PII (Personally Identifiable Information) detection before files reach the clean bucket.</p>
</li>
</ul>
<h3>2. Implementing Content De-duplication</h3>
<p>To optimize storage costs and minimize redundant processing (like malware scanning or image transformation), implement <strong>content-addressable storage</strong>:</p>
<ol>
<li><p><strong>Client-side Hashing:</strong> Compute a SHA-256 digest on the client before upload.</p>
</li>
<li><p><strong>Dedupe Check:</strong> Query DynamoDB to see if the digest already exists in the <code>Clean Bucket</code>.</p>
</li>
<li><p><strong>Logical Mapping:</strong> If a match is found, create a new <code>Relation</code> record pointing to the existing <code>finalKey</code> instead of initiating a new upload.</p>
</li>
</ol>
<h3>3. Proactive Observability and Alarming</h3>
<p>Relying on DLQs is the first step, but production systems require active monitoring. Implement CloudWatch Alarms for:</p>
<ul>
<li><p><strong>Pipeline Failures:</strong> Monitor Step Functions <code>ExecutionsFailed</code> and <code>ExecutionsTimedOut</code>.</p>
</li>
<li><p><strong>Infrastructure Health:</strong> Track Lambda <code>Errors</code> and <code>Throttles</code>, specifically for the image processing and metadata tasks.</p>
</li>
<li><p><strong>Queue Backlog:</strong> Alarm when DLQ message counts exceed zero to ensure human intervention for unhandled edge cases.</p>
</li>
</ul>
<h3>4. Explicit Business-level Retries</h3>
<p>Beyond infrastructure retries, your workflow should handle transient business failures (e.g., a third-party moderation API being temporarily unavailable). Use Step Functions' <code>Retry</code> logic with custom error codes like <code>TransientServiceError</code> to implement sophisticated backoff strategies without cluttering your Lambda code.</p>
<h3>5. Decoupling Notification Infrastructure</h3>
<p>In a multi-service architecture, WebSocket management should often be extracted into a dedicated <strong>Notification Microservice</strong>. The file-processing service would remain a pure producer, publishing <code>UploadStatusChanged</code> events, while the notification service handles delivery across multiple channels (WebSockets, Push Notifications, Email).</p>
<h3>6. Cross-Account Event Distribution</h3>
<p>As your organization grows, consumers of your file-processing events may live in different AWS accounts. Leverage <strong>EventBridge Bus-to-Bus routing</strong> to forward domain events to a central integration bus or directly to consumer accounts, maintaining a clean event-driven contract across team boundaries.</p>
<h2>Production Check-list</h2>
<ul>
<li><p>Replace mock authorizers with robust JWT/OIDC verification.</p>
</li>
<li><p>Implement customer-managed KMS keys for encryption at rest (S3 &amp; DynamoDB).</p>
</li>
<li><p>Protected CloudFront distribution by associating it with a web ACL that includes AWS WAF managed rules and IP-based rate limiting.</p>
</li>
<li><p>Define granular IAM boundaries and S3 Bucket Policies (Least Privilege).</p>
</li>
<li><p>Add structured logging and X-Ray tracing for end-to-end correlation.</p>
</li>
<li><p>Enforce S3 Object Lock or Versioning for compliance-heavy domains.</p>
</li>
</ul>
<h2>Final Thoughts</h2>
<p>In modern web applications, handling file uploads is a multi-stage challenge. You need to ensure security, perform transformations, and keep the user informed, all while maintaining a serverless, cost-effective footprint.</p>
<p>The beauty of this architecture lies in <strong>AWS Service Alignment</strong>. We aren't fighting the platform; we're using each service for what it's naturally good at:</p>
<ul>
<li><p><strong>S3</strong> for durable storage.</p>
</li>
<li><p><strong>GuardDuty</strong> for specialized security.</p>
</li>
<li><p><strong>Step Functions</strong> for stateful orchestration.</p>
</li>
<li><p><strong>EventBridge</strong> for decoupled, event-driven communication.</p>
</li>
</ul>
<p>By separating ingestion from processing and using security as a gatekeeper, you create a system that is secure by default, observable, and ready to scale.</p>
<p>👉 <a href="https://github.com/haZya/upload-file-processing-pipeline"><strong>View the full project on GitHub</strong></a></p>
]]></content:encoded></item><item><title><![CDATA[Mastering Cross-Account SNS to SQS Subscriptions with AWS CDK]]></title><description><![CDATA[In a modern microservices architecture, decoupled communication is king. AWS SNS (Simple Notification Service) and SQS (Simple Queue Service) are the bedrock of this decoupling. But as your organizati]]></description><link>https://blog.hazya.dev/mastering-cross-account-sns-to-sqs-subscriptions-with-aws-cdk</link><guid isPermaLink="true">https://blog.hazya.dev/mastering-cross-account-sns-to-sqs-subscriptions-with-aws-cdk</guid><category><![CDATA[AWS]]></category><category><![CDATA[aws-cdk]]></category><category><![CDATA[cloud architecture]]></category><category><![CDATA[Microservices]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Sun, 22 Mar 2026 00:33:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/2481c863-7673-434a-8b9c-47e9b13736ef.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In a modern microservices architecture, decoupled communication is king. AWS SNS (Simple Notification Service) and SQS (Simple Queue Service) are the bedrock of this decoupling. But as your organization grows and you move toward a multi-account strategy, you'll inevitably hit a common hurdle: <strong>How do you subscribe an SQS queue in Account B to an SNS topic in Account A?</strong></p>
<p>Cross-account subscriptions can be tricky because of permission boundaries and the "Pending Confirmation" dance. In this post, we’ll explore two ways to handle this using AWS CDK, and why one is clearly superior.</p>
<h2>The Scenario</h2>
<p>Imagine a <strong>Core Service</strong> in Account A that publishes "Task Status" events to an SNS Topic. A <strong>Consumer Service</strong> in Account B needs to process these events via an SQS Queue.</p>
<blockquote>
<p><strong>Important:</strong> For native SNS-to-SQS subscriptions, make sure the Topic and Queue are in the <strong>same AWS Region</strong>.</p>
</blockquote>
<hr />
<h2>Method 1: Subscribe as the Queue Owner (Recommended)</h2>
<p>In this approach, the owner of the SNS Topic (Account A) "opens the door" by granting permission, and the owner of the SQS Queue (Account B) "walks in" by creating the subscription.</p>
<h3>Granting Consumer Account the Permission to Subscribe</h3>
<p>Instead of managing every single subscription, the topic owner grants <code>sns:Subscribe</code> permissions to the consumer account.</p>
<p><strong>CDK Implementation (Account A):</strong></p>
<pre><code class="language-typescript">const topic = new sns.Topic(this, "StatusTopic", {
  fifo: true,
  contentBasedDeduplication: true,
});

// Grant a specific account permission to subscribe
topic.grantSubscribe(new iam.AccountPrincipal("123456789012"));

// OR: If you are in an AWS Organization, grant access to the entire Org
topic.addToResourcePolicy(new iam.PolicyStatement({
  actions: ["sns:Subscribe"],
  resources: [topic.topicArn],
  principals: [new iam.AnyPrincipal()],
  conditions: {
    StringEquals: { "aws:PrincipalOrgID": "o-xxxxxxxxxx" }
  }
}));
</code></pre>
<h3>Subscribing SQS Queue to the Topic</h3>
<p>Now, the consumer can subscribe their queue to the topic using the Topic ARN. Since they have permission on the topic and own the queue, the subscription is confirmed <strong>instantly</strong>.</p>
<p><strong>CDK Implementation (Account B):</strong></p>
<pre><code class="language-typescript">const queue = new sqs.Queue(this, "ConsumerQueue", { fifo: true });
const topicArn = "arn:aws:sns:us-east-1:111122223333:StatusTopic.fifo";

const topic = sns.Topic.fromTopicArn(this, "ImportedTopic", topicArn);

topic.addSubscription(new subs.SqsSubscription(queue, {
  rawMessageDelivery: true,
  filterPolicy: {
    status: sns.SubscriptionFilter.stringFilter({ allowlist: ["CREATED"] }),
  },
}));
</code></pre>
<blockquote>
<p><strong>Deployment tip:</strong> Deploy Account A (Topic policy/grants) before Account B creates the subscription.</p>
</blockquote>
<h3>The "Self-Service" Advantage</h3>
<p>This is the <strong>Gold Standard</strong> for microservices. Account A provides the "Event Bus" (the Topic), and consumers can:</p>
<ul>
<li><p>Create as many queues as they need.</p>
</li>
<li><p>Define their own Filter Policies without bothering the producer.</p>
</li>
<li><p>Manage their own DLQs and retry logic.</p>
</li>
<li><p>Scale independently without any manual "confirmation" steps.</p>
</li>
</ul>
<hr />
<h2>Method 2: Subscribe as the Topic Owner</h2>
<p>Here, the producer in Account A explicitly creates the subscription to the remote queue in Account B.</p>
<h3>Creating a Subscription for the Remote SQS Queue</h3>
<p>Instead of allowing the consumers to initiate the subscriptions, the topic owner creates and manages the subscriptions for the consumers.</p>
<p><strong>CDK Implementation (Account A):</strong></p>
<pre><code class="language-typescript">const queue = sqs.Queue.fromQueueArn(this, "RemoteQueue", "arn:aws:sqs:us-east-1:444455556666:ConsumerQueue.fifo");

topic.addSubscription(new subs.SqsSubscription(queue));
</code></pre>
<h3>The "Symmetric Permission" Requirement</h3>
<p>For this to work, you must also update the <strong>SQS Queue Policy</strong> in Account B to allow the SNS Topic to send messages.</p>
<p><strong>CDK Implementation (Account B):</strong></p>
<pre><code class="language-typescript">// In Account B's CDK code:
queue.addToResourcePolicy(new iam.PolicyStatement({
  actions: ["sqs:SendMessage"],
  resources: [queue.queueArn],
  principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
  conditions: {
    ArnEquals: { "aws:SourceArn": "arn:aws:sns:us-east-1:111122223333:StatusTopic.fifo" },
    StringEquals: { "aws:SourceAccount": "111122223333" }
  }
}));
</code></pre>
<p><strong>The Catch:</strong> When the producer creates the subscription cross-account, it enters a <code>Pending Confirmation</code> state. AWS sends a <code>SubscriptionConfirmation</code> message to the SQS queue. The consumer then needs a confirmation step (manual or automated, e.g., a Lambda that calls <code>ConfirmSubscription</code>). This introduces extra coupling and operational complexity versus Method 1.</p>
<h3>Subscription confirmation</h3>
<blockquote>
<p><strong>Step 1:</strong> In Account B, search for the subscription confirmation message by polling the queue messages.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/29fdb864-c211-478d-aeba-dbdbd0e489b4.png" alt="SQS message with subscription confirmation URL." style="display:block;margin:0 auto" />

<blockquote>
<p>Step 2: Extract the <code>SubscribeURL</code> from the message and enter it in a browser to confirm.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/928f5931-d513-4831-82c9-08367bb5c6be.png" alt="Subscription confirmation result." style="display:block;margin:0 auto" />

<blockquote>
<p>Step 3: Now in Account A, verify that the subscription is confirmed.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/54356be0-642b-4199-ad61-f440c2328134.png" alt="Verify subscription confirmation." style="display:block;margin:0 auto" />

<hr />
<h2>Essential Best Practices</h2>
<h3>1. Use Filter Policies</h3>
<p>Don't make your consumers process every single message. Use <code>SubscriptionFilter</code> to ensure they only get what they need.</p>
<pre><code class="language-typescript">topic.addSubscription(new subs.SqsSubscription(queue, {
  filterPolicy: {
    status: sns.SubscriptionFilter.stringFilter({
      allowlist: ["CREATED", "COMPLETED"],
    }),
  },
}));
</code></pre>
<h3>2. Dead-Letter Queues (DLQs)</h3>
<p>Always attach a DLQ to your SQS queue. If a message fails to process after several retries, it moves to the DLQ instead of blocking the queue.</p>
<pre><code class="language-typescript">const dlq = new sqs.Queue(this, "DeadLetterQueue", {
  fifo: true,
  retentionPeriod: cdk.Duration.days(14),
});

const queue = new sqs.Queue(this, "ConsumerQueue", {
  fifo: true,
  deadLetterQueue: {
    queue: dlq,
    maxReceiveCount: 5, // Move to DLQ after 5 failed attempts
  },
});
</code></pre>
<h3>3. Encryption with KMS</h3>
<p>When using Customer Managed Keys (CMKs) across accounts, KMS permissions depend on the exact service interactions:</p>
<ul>
<li><p><strong>SNS-Encrypted Topics:</strong> Your Publishers (IAM roles or AWS services) must have <code>kms:GenerateDataKey*</code> and <code>kms:Decrypt</code> on the Topic's key to encrypt payloads. The <code>sns.amazonaws.com</code> principal does not need permissions on this key to fan out messages.</p>
</li>
<li><p><strong>SQS-Encrypted Queues:</strong> Because SNS writes to the queue, you must grant the <code>sns.amazonaws.com</code> service principal <code>kms:GenerateDataKey*</code> and <code>kms:Decrypt</code> on the Queue's key. Downstream Consumers also require <code>kms:Decrypt</code> on this key to read the messages.</p>
</li>
<li><p><strong>Cross-Account Setup:</strong> Access requires two-way trust. You must explicitly allow the cross-account actions in both the KMS Key Policy (in the key-owning account) and the IAM Policy (in the calling account). If either is missing, access fails.</p>
</li>
</ul>
<p><a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-least-privilege-policy.html#sqs-use-case-publish-messages-permissions">Grant Amazon SNS KMS permissions to Amazon SNS to publish messages to the queue</a></p>
<pre><code class="language-typescript">// Create the Customer Managed Key (CMK) for the SQS Queue
const queueKey = new kms.Key(this, "QueueKey", {
  enableKeyRotation: true,
  alias: "alias/my-queue-key",
});

// Allow SNS Service Principal to use the Queue's KMS Key
queueKey.addToResourcePolicy(new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
  actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
  resources: ["*"], // Evaluates to this specific key
  conditions: {
    // Security Best Practice: Restrict to the specific cross-account topic
    ArnEquals: { "aws:SourceArn": crossAccountTopicArn }
  }
}));
</code></pre>
<p><a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-least-privilege-policy.html#sqs-restrict-transmission-to-topic">Restrict message transmission to a specific Amazon SNS topic</a></p>
<pre><code class="language-typescript">// Create the Encrypted SQS Queue
const queue = new Queue(this, "MyEncryptedQueue", {
  encryption: sqs.QueueEncryption.KMS,
  encryptionMasterKey: queueKey,
});

// Allow the cross-account SNS topic to publish messages to the Queue
queue.addToResourcePolicy(new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
  actions: ["sqs:SendMessage"],
  resources: [queue.queueArn],
  conditions: {
    ArnEquals: { "aws:SourceArn": crossAccountTopicArn }
  }
}));
</code></pre>
<blockquote>
<p><strong>Tip:</strong> The <code>ArnEquals: { 'aws:SourceArn': crossAccountTopicArn }</code> condition is crucial to prevent the <a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-least-privilege-policy.html#sqs-confused-deputy-prevention">confused deputy problem</a>, ensuring only your specific topic can use this key and queue.</p>
</blockquote>
<h3>4. FIFO Symmetry</h3>
<p>If you're using FIFO (as in the examples), ensure both the Topic and the Queue are FIFO. A FIFO Topic cannot deliver to a standard Queue, and a standard Topic cannot deliver to a FIFO Queue. Also, make sure your publishers provide a <code>MessageGroupId</code>.</p>
<hr />
<h2>Summary</h2>
<p>Cross-account messaging is a powerful pattern for scaling. By <strong>granting subscription permissions at the Topic level (Method 1)</strong>, you enable a self-service model that empowers consumers and keeps your infrastructure code clean and automated.</p>
<h2>References</h2>
<ul>
<li><a href="https://docs.aws.amazon.com/sns/latest/dg/sns-send-message-to-sqs-cross-account.html">AWS Docs: Sending SNS messages to an SQS queue in a different account</a></li>
</ul>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=xIcNTObKIuc">https://www.youtube.com/watch?v=xIcNTObKIuc</a></p>
]]></content:encoded></item><item><title><![CDATA[Protecting Your Users with NSFWJS]]></title><description><![CDATA[In the modern web, user-generated content (UGC) is everywhere. While it drives engagement, it also brings a major challenge: content moderation. How do you prevent inappropriate images from being uplo]]></description><link>https://blog.hazya.dev/protecting-your-users-with-nsfwjs</link><guid isPermaLink="true">https://blog.hazya.dev/protecting-your-users-with-nsfwjs</guid><category><![CDATA[explicit-content-filtering]]></category><category><![CDATA[indecent-content-filtering]]></category><category><![CDATA[# content moderation]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Thu, 19 Mar 2026 22:04:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/b47cddeb-f4f2-49cc-b31d-8d69ed479b3d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the modern web, user-generated content (UGC) is everywhere. While it drives engagement, it also brings a major challenge: <strong>content moderation</strong>. How do you prevent inappropriate images from being uploaded or displayed without building a massive, expensive backend infrastructure?</p>
<p>Enter <a href="https://www.npmjs.com/package/nsfwjs"><strong>NSFWJS</strong></a>, a powerful, open-source JavaScript library that brings indecent content checking directly to the client's browser.</p>
<p><a href="https://nsfwjs.com/"><strong>Try the live demo</strong></a></p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/8da5f129-781e-4df0-aea9-0c28133dec88.gif" alt="Preview of the NSFWJS demo live classification." style="display:block;margin:0 auto" />

<h2>Why Client-Side Moderation?</h2>
<p>Traditionally, moderation happens on the server. While powerful services like <a href="https://aws.amazon.com/rekognition/">AWS Rekognition</a> or <a href="https://cloud.google.com/vision">Google Cloud Vision</a> are industry standards, they come with trade-offs. You have to upload every user image to their cloud, which introduces latency, privacy concerns, and recurring per-image costs.</p>
<p><strong>NSFWJS flips the script.</strong> By leveraging <a href="https://js.tensorflow.org/">TensorFlow.js</a>, the moderation happens on the user's device. This offers three massive advantages:</p>
<ol>
<li><p><strong>Privacy</strong>: Unlike cloud APIs, the image never has to leave the user's device. This is a huge win for user trust and data privacy compliance.</p>
</li>
<li><p><strong>Scalability</strong>: You offload the heavy lifting (machine learning inference) to the client's hardware. Your servers stay lean, and you avoid the "bill-per-image" model.</p>
</li>
<li><p><strong>Immediate Feedback</strong>: Users get instant results without waiting for a round-trip to a remote server. This enables unique use cases like <strong>real-time video feed or live webcam analysis</strong>, where you can classify several frames per second to provide continuous moderation.</p>
</li>
</ol>
<h2>Categorizing the Web</h2>
<p>NSFWJS doesn't just give you a "Yes/No" answer. It provides probabilities across five distinct classes so that you can choose the level of moderation and intensity you want.</p>
<ul>
<li><p>😐 <strong>Neutral</strong>: Everyday, safe-for-work images.</p>
</li>
<li><p>🎨 <strong>Drawing</strong>: Safe-for-work drawings and anime.</p>
</li>
<li><p>🔥 <strong>Sexy</strong>: Sexually explicit images, but not quite pornography.</p>
</li>
<li><p>🔞 <strong>Hentai</strong>: Hentai and pornographic drawings.</p>
</li>
<li><p>🔞 <strong>Porn</strong>: Pornographic images and sexual acts.</p>
</li>
</ul>
<h2>Getting Started in 30 Seconds</h2>
<p>Integrating NSFWJS is incredibly straightforward. Here’s how you can classify an image element in your web app:</p>
<pre><code class="language-javascript">import * as nsfwjs from "nsfwjs";

// Load the model (MobileNetV2 is the default)
const model = await nsfwjs.load();

// Classify an image element, video, or canvas
const img = document.getElementById("user-upload-preview");
const predictions = await model.classify(img);

console.log("Predictions:", predictions);
</code></pre>
<h3>Advanced: Optimizing for Production (Tree-Shaking)</h3>
<p>If you're building a production app, you might want to keep your bundle as small as possible. By using the <code>nsfwjs/core</code> entry point, you can manually register only the models you need:</p>
<pre><code class="language-javascript">import { load } from "nsfwjs/core";
import { MobileNetV2Model } from "nsfwjs/models/mobilenet_v2";

// Only bundles and loads the specific model you want
const model = await load("MobileNetV2", {
  modelDefinitions: [MobileNetV2Model],
});

const predictions = await model.classify(img);
</code></pre>
<p><em>For a comprehensive, real-world React implementation using Web Workers and local caching in the browser, check out the</em> <a href="https://nsfwjs.com/"><em>Demo</em></a> <em>in the</em> <a href="https://github.com/infinitered/nsfwjs/tree/master/examples/nsfw_demo"><em>GitHub repository</em></a><em>.</em></p>
<h2>Shared Responsibility: Frontend + Backend</h2>
<p>While client-side moderation is a powerful first line of defense, it should <strong>never be your only line of defense</strong>. A savvy user can always bypass frontend code. For a robust system, moderation is a shared responsibility:</p>
<ul>
<li><p><strong>Frontend</strong>: Provides instant feedback to the user, reduces server load, and acts as a filter for the majority of uploads.</p>
</li>
<li><p><strong>Backend</strong>: Acts as the ultimate source of truth. You should always implement a middleware or a server-side hook (using <code>@tensorflow/tfjs-node</code> with <code>NSFWJS</code>) to verify images before they are permanently stored or served to other users.</p>
</li>
</ul>
<h3>Backend Verification (Node.js)</h3>
<p>To ensure your moderation is tamper-proof, you should also verify the content on your server. Here is a quick example of how to classify an image in a Node.js environment:</p>
<pre><code class="language-javascript">const tf = require("@tensorflow/tfjs-node");
const nsfw = require("nsfwjs");

async function verifyImage(imageBuffer) {
  // Load the model (ideally from a local file)
  const model = await nsfw.load(); 

  // Convert buffer to a 3D Tensor
  const image = await tf.node.decodeImage(imageBuffer, 3);

  // Classify and dispose of the tensor to prevent memory leaks
  const predictions = await model.classify(image);
  image.dispose();

  return predictions;
}
</code></pre>
<h2>Performance &amp; Flexibility</h2>
<p>Whether you're building a lightweight mobile site or a heavy-duty web app, NSFWJS has you covered:</p>
<ul>
<li><p><strong>Multiple Models</strong>: Choose between <code>MobileNetV2</code> (fast &amp; small), <code>MobileNetV2Mid</code> (balanced), or <code>InceptionV3</code> (most accurate but also the heaviest).</p>
</li>
<li><p><strong>Tree-Shaking</strong>: Using the <code>nsfwjs/core</code> entry point allows you to bundle only the models you need, keeping your JS payload tiny.</p>
</li>
<li><p><strong>Host Your Own Models</strong>: While models can be bundled into your JS, for optimal performance, <a href="https://github.com/infinitered/nsfwjs?tab=readme-ov-file#host-your-own-model">host the model files</a> directly on your CDN. This allows you to load model binaries directly and enables browsers to cache them separately, reducing your initial JS bundle size.</p>
</li>
<li><p><strong>Node.js Support</strong>: Need to do some server-side checks, too? NSFWJS works perfectly with <code>@tensorflow/tfjs-node</code>.</p>
</li>
<li><p><strong>Backend Selection</strong>: It supports <code>WebGL</code>, <code>WASM</code>, and even the new <code>WebGPU</code> for blazing-fast performance.</p>
</li>
</ul>
<h2>Conclusion</h2>
<p>As developers, we have a responsibility to create safe digital spaces. NSFWJS makes it easy to add a layer of protection to your applications without sacrificing user privacy or breaking the bank on server costs.</p>
<p><strong>Note on Accuracy:</strong> Machine learning is never 100% perfect. NSFWJS is highly accurate (~90-93%), but false positives can happen. If you encounter an image that is misclassified, consider <a href="https://github.com/infinitered/nsfwjs/issues"><strong>reporting it on GitHub</strong></a> so that it can be used to continue improving the model for everyone.</p>
<p>Give it a star on <a href="https://github.com/infinitered/nsfwjs"><strong>GitHub</strong></a> and join the community of contributors making the web a safer place!</p>
]]></content:encoded></item><item><title><![CDATA[Elevate Your UI with Dynamic Text Shadows in React with ShineJS]]></title><description><![CDATA[As developers, we're always looking for that extra "pop" to make our interfaces stand out. Whether you're a fan of neumorphism or just want to add a bit of tactile depth to your typography, static shadows often fall flat. They don't react to the envi...]]></description><link>https://blog.hazya.dev/elevate-your-ui-with-dynamic-text-shadows-in-react-with-shinejs</link><guid isPermaLink="true">https://blog.hazya.dev/elevate-your-ui-with-dynamic-text-shadows-in-react-with-shinejs</guid><category><![CDATA[ShineJS]]></category><category><![CDATA[text-shadow]]></category><category><![CDATA[React]]></category><category><![CDATA[neumorphism]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Mon, 16 Feb 2026 02:29:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771207393967/752f45cb-1dca-4ff3-88f5-ead2df62058f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>As developers, we're always looking for that extra "pop" to make our interfaces stand out. Whether you're a fan of <strong>neumorphism</strong> or just want to add a bit of tactile depth to your typography, static shadows often fall flat. They don't react to the environment, and they certainly don't feel "alive."</p>
<p>That’s why I built <a target="_blank" href="https://github.com/haZya/shinejs"><strong>ShineJS</strong></a>, a modern, lightweight library designed to bring dynamic, light-reactive shadows to your React and Next.js projects.</p>
<p>In this article, I’ll show you how to get started with the core component and provide an interactive playground where you can experiment with <strong>neumorphic-text</strong> effects in real-time.</p>
<h2 id="heading-what-is-shinejs">What is ShineJS?</h2>
<p><a target="_blank" href="https://github.com/haZya/shinejs">ShineJS</a> is an ESM-only TypeScript library based on the initial work from <a target="_blank" href="https://github.com/bigspaceship/shine.js">bigspaceship/shine.js</a> that calculates and injects multi-layered shadows based on a virtual light source. By tracking the mouse position (or a fixed point), it creates a sense of physical presence for your text and UI elements.</p>
<p>It's particularly effective for:</p>
<ul>
<li><p><strong>Neumorphism</strong> aesthetics where soft, directional shadows define the UI.</p>
</li>
<li><p>Hero sections that need a premium, interactive feel.</p>
</li>
<li><p>Visualizing depth in typography-heavy designs.</p>
</li>
</ul>
<h2 id="heading-quick-start-the-component">Quick Start: The <code>&lt;Shine /&gt;</code> Component</h2>
<p>The easiest way to add a shine effect is to use the high-level React component. It handles the ref management and updates automatically.</p>
<h3 id="heading-installation">Installation</h3>
<pre><code class="lang-bash">npm install @hazya/shinejs
</code></pre>
<h3 id="heading-basic-usage">Basic Usage</h3>
<p>Here is a simple example of a heading that follows your mouse:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> { Shine } <span class="hljs-keyword">from</span> <span class="hljs-string">"@hazya/shinejs/react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">HeroHeading</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"bg-slate-100 p-20 flex justify-center"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Shine</span>
        <span class="hljs-attr">as</span>=<span class="hljs-string">"h1"</span>
        <span class="hljs-attr">className</span>=<span class="hljs-string">"text-6xl font-black text-slate-200 uppercase tracking-tighter"</span>
        <span class="hljs-attr">options</span>=<span class="hljs-string">{{</span>
          <span class="hljs-attr">light:</span> {
            <span class="hljs-attr">position:</span> "<span class="hljs-attr">followMouse</span>",
            <span class="hljs-attr">intensity:</span> <span class="hljs-attr">1.2</span>,
          },
          <span class="hljs-attr">config:</span> {
            <span class="hljs-attr">blur:</span> <span class="hljs-attr">30</span>,
            <span class="hljs-attr">opacity:</span> <span class="hljs-attr">0.2</span>,
            <span class="hljs-attr">offset:</span> <span class="hljs-attr">0.15</span>,
            <span class="hljs-attr">shadowRGB:</span> { <span class="hljs-attr">r:</span> <span class="hljs-attr">15</span>, <span class="hljs-attr">g:</span> <span class="hljs-attr">23</span>, <span class="hljs-attr">b:</span> <span class="hljs-attr">42</span> }, // <span class="hljs-attr">Slate-900</span>
          },
        }}
      &gt;</span>
        Dynamic Depth
      <span class="hljs-tag">&lt;/<span class="hljs-name">Shine</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}
</code></pre>
<iframe src="https://stackblitz.com/github/haZya/shinejs-examples/tree/main/shinejs-simple-demo-react?embed=1&amp;file=src%2FApp.tsx&amp;hideExplorer=1&amp;hideNavigation=1&amp;view=preview" style="width:100%;height:400px;border:0"></iframe>

<h2 id="heading-interactive-playground">Interactive Playground</h2>
<p>I believe the best way to understand a tool is to break it. Below is a live playground that showcases the ShineJS options.</p>
<p>You can tweak the <strong>intensity</strong>, <strong>blur</strong>, and <strong>offset,</strong> etc., to see how the shadow layers interact. This playground uses the <code>useShine</code> hook under the hood to give you full control over the rendering logic.</p>
<iframe src="https://stackblitz.com/github/haZya/shinejs-examples/tree/main/shinejs-playground-demo-react?embed=1&amp;file=src%2FApp.tsx&amp;hideExplorer=1&amp;hideNavigation=1&amp;view=preview" style="width:100%;height:950px;border:0"></iframe>

<pre><code class="lang-javascript"><span class="hljs-string">"use client"</span>;

<span class="hljs-keyword">import</span> { Shine } <span class="hljs-keyword">from</span> <span class="hljs-string">"@hazya/shinejs/react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">PlaygroundStarter</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Shine</span>
      <span class="hljs-attr">as</span>=<span class="hljs-string">"h1"</span>
      <span class="hljs-attr">options</span>=<span class="hljs-string">{{</span>
        <span class="hljs-attr">light:</span> {
          <span class="hljs-attr">position:</span> "<span class="hljs-attr">followMouse</span>",
          <span class="hljs-attr">intensity:</span> <span class="hljs-attr">1</span>,
        },
        <span class="hljs-attr">config:</span> {
          <span class="hljs-attr">numSteps:</span> <span class="hljs-attr">5</span>,
          <span class="hljs-attr">opacity:</span> <span class="hljs-attr">0.15</span>,
          <span class="hljs-attr">opacityPow:</span> <span class="hljs-attr">1.2</span>,
          <span class="hljs-attr">offset:</span> <span class="hljs-attr">0.15</span>,
          <span class="hljs-attr">offsetPow:</span> <span class="hljs-attr">1.8</span>,
          <span class="hljs-attr">blur:</span> <span class="hljs-attr">40</span>,
          <span class="hljs-attr">blurPow:</span> <span class="hljs-attr">1</span>,
          <span class="hljs-attr">shadowRGB:</span> { <span class="hljs-attr">r:</span> <span class="hljs-attr">0</span>, <span class="hljs-attr">g:</span> <span class="hljs-attr">0</span>, <span class="hljs-attr">b:</span> <span class="hljs-attr">0</span> },
        },
      }}
    &gt;</span>
      Shine Playground
    <span class="hljs-tag">&lt;/<span class="hljs-name">Shine</span>&gt;</span></span>
  );
}
</code></pre>
<h3 id="heading-things-to-try">Things to Try:</h3>
<ul>
<li><p><strong>Shadow Color:</strong> Change the <code>shadowRGB</code> to match your background for a true neumorphic look.</p>
</li>
<li><p><strong>Opacity Power:</strong> Crank up the <code>opacityPow</code> to see how it affects the falloff of the shadow layers.</p>
</li>
<li><p><strong>Light Position:</strong> Switch from <code>followMouse</code> to a <code>fixed</code> point to simulate a static light source in your UI.</p>
</li>
</ul>
<h2 id="heading-typography-and-localization">Typography and Localization</h2>
<p>One of the strengths of ShineJS is its adaptability. Because it works with standard CSS text-shadows and box-shadows, it respects your typography choices, including font-family and font-weight.</p>
<p>In this next example, you can see how the effect maintains its integrity across different font families and languages. Whether it's bold Sans-Serif or elegant Serif, the shadows wrap perfectly around every glyph.</p>
<iframe src="https://stackblitz.com/github/haZya/shinejs-examples/tree/main/shinejs-typography-demo-react?embed=1&amp;file=src%2FApp.tsx&amp;hideExplorer=1&amp;hideNavigation=1&amp;view=preview" style="width:100%;height:740px;border:0"></iframe>

<pre><code class="lang-javascript"><span class="hljs-string">"use client"</span>;

<span class="hljs-keyword">import</span> { Shine } <span class="hljs-keyword">from</span> <span class="hljs-string">"@hazya/shinejs/react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">TypographyExample</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Shine</span>
      <span class="hljs-attr">as</span>=<span class="hljs-string">"h1"</span>
      <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span>
        <span class="hljs-attr">fontFamily:</span> "<span class="hljs-attr">Georgia</span>, '<span class="hljs-attr">Times</span> <span class="hljs-attr">New</span> <span class="hljs-attr">Roman</span>', <span class="hljs-attr">serif</span>",
        <span class="hljs-attr">fontWeight:</span> <span class="hljs-attr">700</span>,
        <span class="hljs-attr">fontStyle:</span> "<span class="hljs-attr">italic</span>",
      }}
      <span class="hljs-attr">options</span>=<span class="hljs-string">{{</span>
        <span class="hljs-attr">light:</span> { <span class="hljs-attr">position:</span> "<span class="hljs-attr">followMouse</span>", <span class="hljs-attr">intensity:</span> <span class="hljs-attr">1.15</span> },
        <span class="hljs-attr">config:</span> {
          <span class="hljs-attr">blur:</span> <span class="hljs-attr">38</span>,
          <span class="hljs-attr">offset:</span> <span class="hljs-attr">0.12</span>,
          <span class="hljs-attr">opacity:</span> <span class="hljs-attr">0.32</span>,
          <span class="hljs-attr">shadowRGB:</span> { <span class="hljs-attr">r:</span> <span class="hljs-attr">17</span>, <span class="hljs-attr">g:</span> <span class="hljs-attr">24</span>, <span class="hljs-attr">b:</span> <span class="hljs-attr">39</span> },
        },
      }}
    &gt;</span>
      Typography Shine
    <span class="hljs-tag">&lt;/<span class="hljs-name">Shine</span>&gt;</span></span>
  );
}
</code></pre>
<h3 id="heading-why-this-matters-for-neumorphism">Why this matters for Neumorphism</h3>
<p>Neumorphic design relies heavily on the relationship between the light source and the "material" of the UI. By adjusting the typography options alongside ShineJS parameters, you can ensure that your <strong>neumorphic text</strong> remains readable and visually consistent across all device types and screen resolutions.</p>
<h2 id="heading-built-with-accessibility-in-mind">Built with Accessibility in Mind</h2>
<p>Dynamic text effects can sometimes be a nightmare for screen readers if not handled correctly. When ShineJS splits your text into individual characters to apply granular shadows, it automatically manages the ARIA attributes for you.</p>
<p>The original element receives an <code>aria-label</code> containing the full text, while the internal split-text structure is marked with <code>aria-hidden="true"</code>. This ensures that screen readers see a single, clean string of text rather than a fragmented series of letters, keeping your <strong>neumorphic text</strong> accessible to everyone.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>ShineJS is designed to be unobtrusive yet impactful. It doesn't require heavy canvas rendering or complex WebGL setups. It uses smart CSS injection that follows the physics of light.</p>
<p>If you're building a landing page or a creative portfolio, give <code>@hazya/shinejs</code> a try.</p>
<ul>
<li><p><strong>Documentation:</strong> <a target="_blank" href="http://shinejs.vercel.app/docs">shinejs.vercel.app/docs</a></p>
</li>
<li><p><strong>GitHub:</strong> <a target="_blank" href="https://github.com/haZya/shinejs">haZya/shinejs</a></p>
</li>
</ul>
<p>Happy coding!</p>
]]></content:encoded></item></channel></rss>