Tyler M Kontra

Quantum Bookmark Platform: An AWS Project

Quantum Bookmark: Enterprise-Grade Link Saving

I built Quantum Bookmark Platform to get some Rust and AWS deployment practice. It is a tiny bookmark manager: save a URL, it goes into a queue to capture metadata such as the page title, description, and favicon. Try it out: bookmark.tylerkontra.com

I started with ECS Fargate, an Application Load Balancer, NAT Gateway, WAF, DynamoDB, and SQS. It worked. Then I noticed I had built infrastructure costing roughly $105-$125/mo for an app that would likely sit idle most of…forever.

What I Was Building

I wanted enough moving parts to practice cloud-native development without inventing fake enterprise features:

  • Svelte frontend
  • Rust Axum API
  • Rust background worker
  • DynamoDB persistence
  • SQS job queue and dead-letter queue
  • CloudFormation infrastructure

The application has no sign-up flow or accounts. It creates anonymous browser sessions instead. The browser gets an opaque HttpOnly cookie, and the application gives the user a one-time recovery token for restoring their session if the cookie is lost.

The main flow looks like this:

  1. The browser calls GET /api/bookmarks.
  2. The API creates an anonymous session if no valid cookie exists.
  3. The user submits a URL.
  4. The API validates the URL and creates a bookmark in the queued state.
  5. The API sends an enrichment job to SQS.
  6. The worker fetches metadata and marks the bookmark complete or failed.
  7. The frontend polls while the bookmark remains pending.

That distinction matters because SQS provides at-least-once delivery, so duplicate messages are normal. The worker claims a bookmark with a conditional lease and version guard. If another message arrives after that bookmark is already being processed or has finished, the claim fails and the duplicate message can be acknowledged safely.

The Old Way

The original deployment was conventional ECS:

flowchart TD
    subgraph ECS Cluster
    api
    worker
    end
    cf[Cloudflare DNS]@{shape: cloud} --> waf[AWS WAF]
    waf --> alb[ALB with ACM HTTPS]
    alb --> api[ECS Fargate API]
    worker[ECS Fargate Worker] --> ddb
    worker -- consumes --> sqs@{ shape: das, label: "SQS Enrichment Queue" }
    api -- produces --> sqs
    api --> ddb[(DynamoDB)]
    worker --> nat[NAT Gateway]
    nat --> internet[Public Internet]
    classDef fixed fill:#fee2e2,stroke:#b91c1c,color:#450a0a;
    class waf,alb,api,worker,nat fixed;

The API ran with a desired count of 2; the worker ran with a desired count of 1. Both ARM64 tasks used 512 CPU and 1024 MiB, built from the same image with different commands. The tasks lived in private subnets, and the NAT Gateway gave them outbound access for metadata fetching.

This was not bad architecture. It gave me private tasks, predictable ALB health-check rollouts, deployment circuit breaker, independently deployable API and worker, and WAF rate limiting.

But it had fixed costs that did not care whether anyone saved a bookmark:

  • Fargate: about $43/mo.
  • NAT Gateway: about $33/mo, plus data.
  • ALB: about $16-$22/mo.
  • WAF: about $9/mo.
  • CloudWatch logs and alarms: few dollars.

The total baseline landed around $105-$125/mo before real traffic. At this point, I stopped treating cost as post-deploy cleanup. “Can this run for a few dollars per month?” had become an architecture requirement.

Things That Held Up

Moving the runtime did not mean starting the application over. DynamoDB and SQS still fit the problem.

The key points:

  1. Small queue messages kept state transitions simple. A queue message points at a bookmark instead of duplicating its state.
  2. Conditional DynamoDB updates made worker idempotent under duplicate SQS delivery.
  3. DynamoDB access patterns matched the app: resolve a session, list bookmarks by session and creation time, update a bookmark conditionally, and expire old anonymous data with TTL.
  4. URL enrichment treats SSRF as a core design concern, not a later hardening task. The worker validates the scheme, blocks private and AWS metadata addresses, validates redirects, limits response time and body size, caps redirects and decompression, and parses HTML safely.

None of this required ECS. These were application boundaries, not hosting choices.

Bugs Deployment Found

The first ECS deployment found bugs I was glad to catch before calling the project finished.

The Dockerfile used dummy Rust source files to cache dependency builds. My final build did not force the real source files to recompile correctly, so the image shipped dummy binaries. The quick fix was touching the real source files before the final release build:

# Dockerfile
COPY crates/ ./crates/
COPY migrations/ ./migrations/

RUN find crates -name '*.rs' -exec touch {} + \
    && CARGO_BUILD_JOBS=1 cargo build --release \
      -p qbp-api -p qbp-worker \
      --bin qbp-api --bin qbp-worker --bin sqs-lambda

A missing .env file was also fatal in containers. That made sense locally, but deployed configuration comes from environment variables and secrets. I changed the loader to ignore a missing .env file.

The worker originally exited on a transient loop failure. ECS interpreted this as a failed task and rolled the deployment back. I changed the loop to log transient errors and retry instead of exiting the process.

The most application-specific bug was a session ID mismatch. The application created sess_ IDs while the worker validator expected ses_. SQS jobs were acknowledged without processing, leaving bookmarks stuck in queued. The fix was accepting the correct prefix and adding test coverage.

I also learned that Cloudflare proxying changes WAF rate limiting. With “orange-cloud” enabled, AWS WAF sees a Cloudflare edge IP unless the rule aggregates CF-Connecting-IP. I removed WAF in the serverless version for cost reasons, but this is worth remembering if I add it back.

The New Way

I kept DynamoDB and SQS, then replaced ECS, ALB, NAT, and WAF with Lambda:

flowchart TD
    cf[Cloudflare Proxied DNS] --> url[Lambda Function URL]
    url --> api[Axum API Handler]
    api --> ddb[(DynamoDB)]
    api --> sqs[SQS Enrichment Queue]
    sqs --> worker[Worker Lambda]
    worker --> ddb

The API still runs as an Axum server. Lambda Web Adapter bridges a Lambda invocation to the HTTP server, which avoided a full API rewrite. The worker is now an SQS-triggered Lambda instead of an always-on poller.

deployment/cdk/lib/qbp-serverless-stack.ts builds two ARM64 Lambda images from the same Dockerfile:

const api = new lambda.DockerImageFunction(this, "ApiFunction", {
  architecture: lambda.Architecture.ARM_64,
  code: lambda.DockerImageCode.fromImageAsset(root, {
    file: "Dockerfile",
    target: "lambda-api",
    platform: ecrAssets.Platform.LINUX_ARM64,
  }),
  memorySize: 512,
  timeout: cdk.Duration.seconds(15),
});

lambda-api copies Lambda Web Adapter and starts the existing qbp-api binary. lambda-worker starts a separate sqs-lambda binary. The API gets a 15s timeout; the worker gets 60s because outbound metadata fetching needs more room.

The SQS event source uses batches of five and reports partial failures:

worker.addEventSource(
  new eventSources.SqsEventSource(queue, {
    batchSize: 5,
    reportBatchItemFailures: true,
  }),
);

The worker returns only failed message IDs, so successful messages from the same batch do not replay:

if result.is_err() {
    batch_item_failures.push(BatchFailure {
        item_identifier: record.message_id,
    });
}

Cloudflare points the custom hostname at the Lambda Function URL host with proxying enabled. Cloudflare handles browser TLS for the custom hostname; the AWS certificate remains for the Function URL hostname.

One Trade-off I Still Have

The ECS version used an outbox flow. The API wrote a bookmark and an outbox record, then the always-on worker found pending outbox rows and published them to SQS.

The serverless API now creates a bookmark and sends an SQS job directly. This removes the need for an idle dispatcher, but it is not atomic: a DynamoDB write can succeed while SendMessage fails. The current API logs a warning and returns a response, leaving the bookmark queued until the user retries or another repair path picks it up.

sequenceDiagram
    participant B as Browser
    participant A as API Lambda
    participant D as DynamoDB
    participant Q as SQS

    B->>A: POST /api/bookmarks
    A->>D: Create queued bookmark
    D-->>A: Bookmark saved
    A->>Q: Send enrichment job
    alt SQS accepts job
        Q-->>A: Success
        A-->>B: 202 queued
    else SQS publish fails
        Q-->>A: Error
        A-->>B: 202 queued
        Note over D: Bookmark remains queued
    end

Outbox records still exist because the old data model and table index still own them. I chose not to remove them during the runtime migration. For a personal app, this was an acceptable simplification, but periodic queued-bookmark recovery or a serverless outbox dispatcher is the next reliability improvement if I keep building it.

Moving Stack Ownership

One migration concern was CloudFormation ownership. The original qbp-dev-deployment stack owned runtime and durable resources. Deleting ECS resources carelessly could have deleted DynamoDB tables or SQS queues too.

I converted original stack to data-only:

  • Keep DynamoDB tables.
  • Keep SQS queue and DLQ.
  • Remove ECS, ALB, NAT, WAF, VPC, task roles, and old alarms.

The new qbp-dev-serverless stack owns the API Lambda Function URL, worker Lambda, Lambda IAM roles, session secret, and SQS event-source mapping. A small budget stack owns a $2 actual-cost alarm.

Splitting the stacks let me replace compute without moving or deleting durable data. The $2 alarm is deliberately aggressive: expected monthly cost can reach roughly $3, but I want an early signal when usage or configuration drifts.

Verifying It

Before removing the ECS runtime, I checked the full path:

  1. Load the app through the ALB over HTTPS.
  2. Call GET /api/bookmarks and confirm session cookie.
  3. POST /api/bookmarks and confirm 202 queued.
  4. Confirm the SQS queue drains.
  5. Confirm the worker enriches the bookmark.

After the serverless deployment, I repeated the same path through the Lambda Function URL. The API set a secure session cookie, the SQS-triggered worker completed enrichment, and bookmark detail returned status: complete, title Example Domain, and HTTP status 200.

I then converted the ECS stack to data-only, verified the ALB was gone, and ran the serverless smoke test again.

Cost Afterward

Expected low-traffic cost is now roughly $1-$3/mo:

  • Lambda invocations and duration.
  • DynamoDB on-demand reads and writes.
  • SQS requests.
  • CloudWatch logs.
  • ECR image storage.
  • Secrets Manager, about $0.40/mo for session secret.

There is still a cost floor, but it matches the app’s usage far better than always-on runtime.

TODOs

  • Add queued-bookmark recovery for direct API-to-SQS publish failures.
  • Alert on repeated worker failures, not only total AWS spend.
  • Test SSRF protections against redirect and DNS-rebinding cases.
  • Decide whether unused outbox records and index should be removed in later data migration.

ECS was operationally clean, but economically wrong for this app. Lambda gave me lower idle cost without throwing away queue processing, conditional persistence, or the Rust services I wanted to practice. More importantly, the migration made cost part of the design instead of a bill surprise.