Architecture

How Nautacloud is built

A free static-hosting platform for developers in Cuba, running entirely serverless on AWS. This page documents every part of it — what it does, why it was chosen, what was rejected, how it fails, and what it costs.

In one minute

Nautacloud gives developers who cannot open an AWS, Vercel or Heroku account — because those require an international credit card — a way to publish real websites on the same infrastructure everyone else uses. One person operates it, it is free to its users, and it is designed so that it cannot become expensive.

Three constraints drove every decision, and they are unusual enough to be worth stating before the diagrams:

  • Bandwidth is metered and expensive. Every kilobyte the browser downloads is money out of a user's pocket. That is why the interface has a hard payload budget and why this page loads its own text separately.
  • Connections drop. Any flow that requires several uninterrupted minutes online will fail. Uploads are resumable and every write is idempotent.
  • It has to stay free. There is no paid tier to fall back on, so limits are enforced in code and a spending guard can shut off public traffic before a bill arrives.

The result is nine CDK stacks, 48 Lambda handlers, one DynamoDB table and one S3 bucket, with no servers, no containers and nothing billed by the hour.

  • Compute

    Serverless

    48 Lambda handlers, Node 22 on arm64. No servers or containers.

  • Region

    us-east-1

    Lowest round-trip latency to Cuba, plus edge locations in Miami and the Caribbean.

  • Interface payload

    ~127 KB

    Gzipped JavaScript on first load, against a hard 200 KB budget.

  • Automated tests

    353

    Across handlers, CDK assertions, the web app and the CLI.

  • Idle cost

    ≈ $0

    Everything scales to zero. Cost tracks use, and use is capped.

  • Data model

    1 table

    A single-table DynamoDB design, on-demand, no capacity to plan.

01Principles and constraints

The audience decides the architecture, not the other way round.

Most hosting platforms optimise for developer convenience and assume bandwidth is free, latency is low and payment is trivial. None of those hold here, so several conventional choices are actively wrong for this product.

No third-party runtime dependencies
No analytics, no font CDNs, no external scripts. Each one is another host that must be reachable from the user's network — and some are not — plus another download to pay for.
Aggressive caching by default
Assets are immutable and cached for a year; HTML is never cached. A repeat visit to a hosted site costs almost nothing to load.
Idempotent, resumable flows
Every multi-step operation survives a dropped connection. Retrying is always safe.
No geographic restrictions
No WAF rule or CloudFront setting may filter by country. Geo-blocking would break the product for the exact people it exists to serve.
Hard limits over billing
Since nobody can be charged for overuse, overuse has to be impossible instead.
Why this way
Stating the constraints as rules makes them reviewable. Any change that adds an external dependency, grows the bundle, or filters by geography is a violation, not a trade-off to be argued case by case.
What it costs
Nothing directly, but they rule out cheaper-to-build options — a managed analytics script or a hosted component library would each have been faster.

02Serving a site: CloudFront, S3 and an edge function

One bucket, one distribution, and a function at the edge that maps a hostname to a prefix.

Every user site lives in one S3 bucket, separated by key prefix, and is served through one CloudFront distribution with a wildcard certificate for *.nautacloud.net.

sites/{subdomain}/releases/{releaseId}/index.html
sites/{subdomain}/releases/{releaseId}/assets/app.a1b2c3.js
sites/{subdomain}/releases/{releaseId}/.manifest.json
How keys are laid out inside the single bucket

A CloudFront Function — JavaScript running at the edge in under a millisecond, before the cache is consulted — reads the Host header and rewrites the request to the right prefix. It also implements single-page-app fallback, serving index.html for unknown paths when a site has asked for it.

  1. BrowserRequests https://mysite.nautacloud.net/about
  2. CloudFront edgeServes from cache if it can; otherwise continues to the origin
  3. CloudFront FunctionReads the Host header, looks up the active release, rewrites the path
  4. S3Returns sites/mysite/releases/{id}/about/index.html
  5. ResponseHTML with no-cache; assets with max-age one year, immutable
Why this way
A bucket and a distribution per site would hit AWS account limits quickly and take minutes to provision. Prefixes make creating a site a database write that takes milliseconds, and the wildcard certificate means no per-site certificate issuance.
What was rejected
Lambda@Edge was rejected: it is billed per request, adds cold starts, and must be deployed to every region. A CloudFront Function is free at this volume and runs in the same millisecond as the request. S3 website hosting was rejected because it cannot serve HTTPS on a custom domain.
When it fails
If the edge function throws, CloudFront serves the request unrewritten and S3 returns 404 — sites break but nothing is corrupted or lost. If S3 is unavailable in the region, cached content keeps serving from the edge.
What it costs
CloudFront's perpetual free tier covers 1 TB out and 10 million requests a month. Edge function invocations are free. S3 storage for every account combined is a few gigabytes at most.

03Immutable releases and instant rollback

A deploy writes a new folder; going live is one atomic pointer write.

A release is never modified after it is written. Publishing creates releases/{releaseId}/, and only when every file is in place does a CloudFront KeyValueStore entry change to point the subdomain at the new release id. That single write is what "going live" means.

Because the pointer is data at the edge and not configuration, switching it takes seconds and needs no distribution deployment and no cache invalidation. Rolling back is the same operation aimed at an older id — it re-uploads nothing, because the old release is still there.

The last 3 releases per site are kept. Older ones are queued for deletion and removed by a background worker.

Why this way
Overwriting files in place makes a deploy non-atomic: for a few seconds a site serves half the old version and half the new one, and a failure halfway through leaves it broken with no way back. An immutable release plus a pointer swap makes a deploy all-or-nothing and makes rollback free.
What was rejected
Storing the active release in DynamoDB would have put a database read on the path of every page view. The KeyValueStore is readable from the edge function directly, at no latency cost.
When it fails
If the upload fails partway, the pointer never moves and visitors keep seeing the previous release — the failed deploy is invisible to them. Orphaned files are cleaned up later.
What it costs
Storage for a handful of old releases per site. The KeyValueStore is free at this scale.

04Publishing: multipart uploads and a worker

The browser or CLI uploads a ZIP; an asynchronous worker validates and publishes it.

Uploads go straight to S3 using presigned URLs; the archive never passes through a Lambda. Sessions are resumable: when a deploy is restarted, the client asks which parts S3 already holds and sends only the rest. A deploy interrupted at 80% resumes at 80%.

The worker is defensive, because a ZIP file is untrusted input:

  • Entry paths are rejected if they escape the target prefix (../), are absolute, or exceed a length limit.
  • The entry count and the decompressed-to-compressed ratio are capped, which stops a decompression bomb.
  • The upload is matched against the site's currentUploadId, so a stale or replayed message cannot publish an old archive.
  • Content types are derived from the file extension rather than trusted from the archive.

Sites can also be published from a public GitHub repository, either on demand or automatically on push through a webhook signed with HMAC-SHA256 and protected against replay by a delivery-id record.

  1. InitiateThe API creates a multipart upload session and returns presigned URLs
  2. Upload partsThe browser or CLI sends parts directly to S3, retrying each one alone
  3. CompleteS3 assembles the archive under uploads/{userId}/{subdomain}/
  4. EventS3 emits an EventBridge event that invokes the worker directly
  5. deploy-workerValidates the archive, writes the release, moves the pointer
Why this way
Uploading through a Lambda would cap sites at the 6 MB request limit and pay for compute while bytes move. Doing the work asynchronously means the browser can close mid-deploy — which, on these connections, it will.
What was rejected
A synchronous "upload and wait" API was rejected: it fails exactly when the connection is worst. A polled SQS queue in front of the worker was dropped too — its consumer long-polls around the clock and burns the request quota even when nothing is deploying; the push path costs nothing at rest.
When it fails
A failed deploy is retried twice on a short delay. After that the event lands in a dead-letter queue, an alarm fires, and the site keeps serving its previous release.
What it costs
A few Lambda invocations of a few seconds each per deploy, and nothing at all between deploys. All inside the always-free tier.

05Incremental deploys

Unchanged files are copied inside S3 instead of being uploaded again.

Every release stores a manifest of SHA-256 hashes, one per file, inside its own prefix. On the next deploy the worker compares manifests and splits the work in two: files whose hash changed are written from the archive, and files whose hash did not are duplicated with `CopyObject`, a server-side operation where the bytes never leave AWS.

A typical site update touches a handful of files. Re-publishing it moves almost nothing.

Why this way
Keeping the manifest inside the release rather than in the database is what lets rollback and incremental deploys compose: rolling back to an old release also restores the exact manifest that describes it, so the next incremental deploy is still correct.
What was rejected
Storing hashes in DynamoDB would have meant a read and a write per file per deploy, and would have gone out of sync the moment a rollback happened.
When it fails
If a manifest is missing or unreadable, the worker falls back to publishing every file — slower, never wrong.
What it costs
Copy requests are billed at a fraction of uploads and consume no bandwidth.

06The API

An HTTP API in front of small, single-purpose Lambda handlers.

API Gateway HTTP API routes to 48 Lambda handlers running Node 22 on arm64. Each handler is its own entry point and its own bundle, with an IAM policy scoped to exactly the table, bucket prefix and queue it uses — nothing more.

Every handler runs through shared middleware providing structured JSON logging, metrics and tracing via AWS Lambda Powertools, uniform error handling, and a single place where responses are shaped. Handlers contain business logic, never plumbing.

Why this way
HTTP API costs roughly a third of REST API and adds less latency; none of the features REST API adds are needed here. arm64 is cheaper than x86 per millisecond at identical performance.
What was rejected
A single monolithic Lambda would have been simpler to deploy but would have forced one IAM role with the union of all permissions — an account-deletion handler and a subdomain-availability check should not share credentials.
When it fails
Handlers are stateless, so a failure affects one request. Throttling and concurrency limits stop one endpoint from starving the rest.
What it costs
The first million requests a month are free, and typical handlers run in tens of milliseconds.

07Identity and authorisation

Cognito for people, hashed personal tokens for the command line, one authorizer for both.

Users authenticate through an Amazon Cognito user pool, with optional TOTP two-factor authentication and a pre-sign-up trigger that rejects disposable email domains.

The CLI cannot use a browser login, so it uses personal access tokens. A token is shown once and stored only as a SHA-256 hash; comparison is constant-time; expiry is checked in code rather than trusted to the database's TTL, which is eventual.

A custom Lambda authorizer accepts either credential and resolves both to the same user id, so every handler behind it is written once. It has one deliberate asymmetry:

  1. Sign inCognito hosted login, with optional TOTP two-factor
  2. TokenThe browser keeps it in session storage, scoped to the tab
  3. RequestSent as an Authorization header to the API
  4. Lambda authorizerVerifies a Cognito JWT, or looks up a hashed CLI token
  5. HandlerReceives a verified user id and, for the browser, group membership
Why this way
Building authentication is a reliable way to build it badly. Cognito provides password policy, recovery, MFA and token rotation as a managed service, at no cost at this scale.
What was rejected
API Gateway's built-in JWT authorizer was used originally and had to be replaced: it can validate a Cognito token but cannot also accept a CLI token. A custom authorizer handles both, with results cached for five minutes to avoid a lookup per request.
When it fails
The authorizer sits in front of every authenticated route, so it is the highest-blast-radius component in the system. It fails closed: any error denies the request.
What it costs
Cognito is free below 10,000 monthly active users. Authorizer invocations are cached and cheap.

08Credits, subscriptions and QvaPay

A planned Nautacloud system for higher subscription limits and credit-based pay-as-you-go usage, with QvaPay as the payment integration.

QvaPay is the payment service Nautacloud plans to use for users who want to support the platform or purchase future paid add-ons. This is a Nautacloud product integration, not a link to QvaPay's API documentation.

What Nautacloud owns
The platform will present the payment flow, associate confirmed payments with the right account, and apply subscription limits or usage credits in the dashboard.
What QvaPay owns
QvaPay handles the payment operation itself. Nautacloud will only trust a payment after it has been confirmed through the integration.
Current status
The integration is being prepared. No real charges, subscriptions or pay-as-you-go transactions are processed by Nautacloud yet.
Why this way
A local payment option can remove a barrier that makes international cloud services unavailable to many developers in Cuba, without putting AWS billing or payment credentials in the hands of users.
What was rejected
International card-only processors were not chosen as the primary path because they do not solve the access problem this platform exists to address. Nautacloud also will not hold card details or implement its own payment processor.
When it fails
Until the integration is live, the dashboard must not present a payment as completed. A failed or delayed confirmation leaves the account unchanged and can be retried safely.
What it costs
The core service remains free within its standard limits. Future subscriptions may provide higher limits, while credits are intended for pay-as-you-go usage; pricing and caps will be documented before either option is enabled.

09Data: one DynamoDB table

A single-table design partitioned by user, with TTL doing the housekeeping.

One table in on-demand mode holds everything. Records are distinguished by sort-key prefix within a USER#{userId} partition, so all of one account's data is a single query. This is the complete list — there is no room reserved for hypothetical future services:

Partition keySort keyWhat it holds
USER#{userId}ACCOUNTSite count, bytes used, quota overrides
USER#{userId}SITE#{subdomain}One site
USER#{userId}UPLOAD#{uploadId}A resumable upload session (expires)
USER#{userId}RATE#{action}#{hour}Hourly rate counter (expires)
USER#{userId}CLITOKEN#{tokenId}A hashed personal access token
SUBDOMAIN#{name}RESERVATIONGlobal subdomain uniqueness
WEBHOOK#{id}CONFIG / DELIVERY#{id}Autodeploy signing config and replay protection
ADMIN_AUDIT{timestamp}#{id}Administrative action trail (expires)

Claiming a subdomain writes the site record and the global reservation in one transaction, so two people racing for the same name cannot both win. Counters that would otherwise need cleaning up — rate limits, upload sessions, audit entries — carry a TTL and are removed by DynamoDB itself.

Why this way
On-demand billing means no capacity planning and no cost while idle. A single table keeps every read of an account to one query, and conditional writes give correctness guarantees that would otherwise need a lock.
What was rejected
A relational database was rejected on cost and shape: the smallest always-on instance costs more per month than this entire platform, and the access patterns are all key lookups.
When it fails
Conditional writes fail loudly rather than corrupting state. A failed transaction leaves nothing half-written.
What it costs
Well inside the free tier. Idle cost is zero.

10Quotas and rate limits

Every limit has one definition in code, enforced in two places.

All limits live in one package, packages/contracts, imported by the interface, the API and the infrastructure alike, so the number a user is shown is the number that is enforced.

LimitValue
Sites per account3
Bytes per upload50 MiB
Bytes per account150 MiB
Releases kept per site3
Entries per archive5,000
Decompression ratio2×

Quotas are checked twice: once when the presigned URLs are issued, so the user is told immediately, and again inside the worker, which is the only check that actually protects the platform. Rate limits are hourly counters written with a conditional increment.

Why this way
A limit enforced only at the point of request is advisory: nothing stops a client calling S3 directly with URLs it already holds. The second check exists because the first can be bypassed.
What was rejected
Per-account AWS budgets were rejected — they report after the fact, and the goal is to make overuse impossible rather than visible.
When it fails
If a quota check cannot read the account record, the operation is refused. Failing closed can be annoying; failing open can be expensive.
What it costs
One extra conditional write per rate-limited action.

11Keeping it free

A spending guard that can switch off public traffic before a bill arrives.

A free service with no payment method has one genuine existential risk: a traffic spike, accidental or hostile, producing a bill nobody can pay. Three layers address it.

  1. Hard limits, so no single account can consume much.
  2. AWS Budgets watching monthly spend and publishing to a topic at defined thresholds.
  3. A cost-guard Lambda that, on the highest threshold, flips a service-state record which makes public routes refuse writes and stop serving new deploys.

The guard is deliberately blunt. Degrading the service is recoverable; an unpayable bill is not.

Why this way
Alerting alone assumes a human is awake and online. This audience's operator may be neither, and a spike can outrun a person.
What was rejected
Relying on AWS free-tier alerts was rejected: they arrive after the spend, and only by email.
When it fails
If the guard fires wrongly, the platform stops accepting deploys while continuing to serve existing sites, and an administrator can clear the state from the dashboard.
What it costs
One Lambda invocation per budget notification.

12Observability

Structured logs, custom metrics, traces, and alarms that page a human.

Every handler emits structured JSON logs with a correlation id, custom metrics in embedded-metric format, and X-Ray traces. One CloudWatch dashboard covers the workers, the queues, the dead-letter queue, API health and egress, and alarms notify an operator by email.

The dashboard finds Lambda errors with a metric-math `SEARCH` expression rather than naming each function. That is not a cosmetic choice — the earlier version imported one ARN per handler across stacks, and CloudFormation refuses to delete an export another stack still imports. Removing any handler deadlocked the deploy. One expression removed about twenty cross-stack exports and the deadlock with them.

Why this way
A platform run by one person needs to say what is wrong without being asked. Structured logs make an incident a query rather than a scroll.
What was rejected
A hosted observability vendor would be better tooling, but it is a third-party dependency, a monthly bill, and another host that must be reachable.
When it fails
Observability failing degrades insight, never the service.
What it costs
Log retention is bounded and metrics are few. Traces are sampled.

13Security

Least privilege everywhere, and a clear boundary around untrusted user content.

  • Per-handler IAM policies. Each Lambda reaches exactly one table, one bucket prefix and one queue.
  • No public buckets. S3 is reachable only through CloudFront, using origin access control.
  • Content Security Policy on the application, with framing denied.
  • Session tokens in session storage, not cookies: scoped to the tab, never sent automatically, and unreadable by any other origin — including sites published by other users.
  • Hashed CLI tokens, compared in constant time.
  • Signed webhooks with replay protection.
  • Untrusted archives treated as hostile: path traversal, entry count and decompression ratio are all bounded.
Why this way
The platform hosts arbitrary code written by strangers and serves it next to an authenticated dashboard. That is the threat model, and it is stated plainly rather than assumed away.
What was rejected
Serving user sites from an entirely separate domain would be the strongest isolation. It was rejected for now because it doubles certificate and DNS management for a single-operator platform; the Public Suffix List gets most of the benefit for none of that.
When it fails
Every check fails closed. The [security page](/security) explains how to report a vulnerability, with a safe harbour.
What it costs
None. Least privilege is free; it is only work.

14Infrastructure as code

Nine CDK stacks, two stages, and no resource created by hand.

All infrastructure is AWS CDK in TypeScript, one stack per domain: certificate, auth, bucket, database, queue, api, sites, web app and observability. Nothing exists in the account that is not in the repository.

There are two stages, dev and prod, and the stage is mandatory — the configuration function throws when it is missing rather than defaulting to production. Stateful resources retain on delete in production and are destroyed in dev, so tearing down dev is safe and tearing down production is not possible by accident.

pnpm --filter @nautacloud/infra diff:dev
pnpm --filter @nautacloud/infra deploy:prod
Every command names its stage; there is no default
Why this way
A platform operated by one person cannot afford undocumented manual changes. Typed infrastructure also lets the same constants file feed both the runtime and the deployment.
What was rejected
Terraform was rejected only because the whole codebase is already TypeScript, and sharing constants and types across application and infrastructure removes a class of mismatch.
When it fails
CloudFormation rolls a failed deployment back. The known sharp edge is cross-stack exports, described in the observability section.
What it costs
None at rest.

15The repository

Four layers; dependencies only point downwards.

FolderWhat it is
apps/The website and dashboard (Next.js, English and Spanish, both always complete) and nauta, the CLI, with zero runtime dependencies.
services/The products — Sites, Domains and the admin and feedback services. Each owns its Lambda handlers.
platform/Backend capabilities shared by every product: the Lambda runtime kit, users, billing, auth, audit.
packages/Framework-free code every client shares: the API contracts, the SDK and the i18n runtime.
infrastructure/The CDK application.

The CLI is bundled with esbuild into a single file and installs no dependencies at all, which matters when npm install runs on a slow, metered connection.

Both interface languages are enforced by the type system: a string that exists in one locale and not the other fails the build. A half-translated interface is not possible.

Why this way
One definition of a limit, one definition of a record shape. When a quota changes, the interface, the API and the infrastructure all change together or the build fails.
When it fails
A shared-type change that breaks a consumer breaks it at compile time, in the same commit.
What it costs
None.

16Trade-offs and what is missing

The honest list of what this design gives up.

Single region
Everything runs in us-east-1. A regional outage takes the API down, though CloudFront keeps serving cached sites. Multi-region was rejected: it multiplies cost and complexity to protect against something rarer than the connectivity problems this audience already faces.
No custom domains
A deliberate product decision, not a technical limit. Buying and operating a domain is not realistic for most of this audience, so every site is a subdomain and the platform is simpler for it.
No server-side code
Static hosting only. Adding user-run functions would multiply the security surface and the cost model, and the project has explicitly retired that roadmap.
Free tier as a design constraint
Some things are impossible within it. If a feature cannot fit, it does not ship — that is the rule that keeps the service free.
One operator
Response times are best-effort and there is no on-call rotation. The architecture compensates with automation, not with promises.

The current known gaps are tracked openly: seven handlers still hold domain logic that belongs in testable modules, and traffic analytics for site owners is designed but deliberately deferred.

Why this way
A design document that lists only strengths is marketing. These are the decisions a reviewer would find anyway.
What it costs
Stating them costs nothing and saves everyone's time.