Manual/Reference

Deploy on AWS (Vercel for the web app)

This page is a runbook for a production Meratyn deployment where the browser application itself is served by Vercel (or any static host) and AWS carries the stateful services and the bandwidth-heavy proxies. Self-hosting everything with plain Docker Compose is covered by Self-host the admin console; the hosted GeoNode/GeoServer layer infrastructure is covered by Deploy hosted layers.

Topology at a glance

The web assets are static. Their host must also route same-origin remote-service requests to the edge service. Everything that serves project data, tiles, or proxy traffic needs a backend.

Repository componentContainerAWS home
Browser app (apps/mappview)mappview nginxVercel (static)
Admin console + Share API (apps/admin, services/admin)adminEC2 (Docker)
Edge tile and AI proxy (services/edge) + its Redisedge, edge-redisEC2 (Docker)
Python sidecar (services/server)bundled in the root Dockerfile image behind nginx at /sidecar/EC2 (Docker)
Object storage (rustfs)rustfsS3 — the container only mimics S3 locally
Docs site, plugin registry, viewer static builddocs, pluginsS3 + CloudFront, or additional Vercel projects
GeoNode databasegeonode-db (PostGIS)RDS for PostgreSQL (PostGIS supported)
GeoNode, GeoServer, celery, memcached, nginxhosted-layers profile containersEC2 (Docker) — no AWS-native replacement exists
Monitoring (gatus, grafana, prometheus, exporters)status, grafana, prometheus, …Skip initially; CloudWatch basic monitoring is free. Re-add on the box if needed

One EC2 instance running the existing docker/compose/admin.yaml (minus the containers replaced above) is the deliberate shape: ECS/Fargate fits admin and edge as individual tasks, but GeoNode + GeoServer need shared volumes (EFS) and end up costing more than a single burstable instance at this scale.

Domain layout

The compose defaults compile one public origin per service into the browser builds (VITE_MAPPVIEW_*_URL). Keep that layout on AWS:

OriginServiceServed by
mappview.…Web appVercel
admin.mappview.…Admin console + Share API (port 8787), and /sidecar/* (port 8790)CloudFront → EC2
tiles.mappview.…Edge proxy (port 8788): tile, OAM, Source Cooperative, and Planetary Computer proxies plus the AI proxyCloudFront → EC2
plugins.mappview.…Plugin registry (registry.json and zips)S3 + CloudFront; the registry document is fetched at runtime, so new entries need no viewer rebuild

Tiers

Start with Tier 1 and enable the hosted-layers profile only when you need organization-managed layers.

  • Tier 1 — admin, edge, web+sidecar image, S3. Share projects, versions, collections, personal API tokens, thumbnails, field photos, tile proxies, and the AI proxy.
  • Tier 2 — adds the hosted-layers profile (GeoNode, GeoServer, celery) plus RDS. Adds layer upload/publication, the catalogue sync, and the admin-owned WMS/WFS/WCS delivery endpoints.

Step 1: S3 bucket and IAM user

Admin talks to object storage through the AWS SDK (services/admin/src/field-photos/rustfs.ts), so the swap from the local rustfs container is environment-only. Field photos upload directly from the browser via a presigned POST; the bucket is created and its policy initialized on first boot, so the IAM user needs bucket-admin rights on that bucket only.

Create a scoped policy (mappview-field-photos is the example bucket):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "MappViewFieldPhotos",
      "Effect": "Allow",
      "Action": [
        "s3:CreateBucket",
        "s3:PutBucketPolicy",
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": ["arn:aws:s3:::mappview-field-photos", "arn:aws:s3:::mappview-field-photos/*"]
    }
  ]
}

Create an IAM user with that policy and store the access keys for Step 4. Credentials are explicit in this code path, so IAM instance roles are not picked up. Enable S3 versioning on the bucket as a cheap undo for deletions.

Later, create a second CloudFront distribution over the bucket (or a cache behavior on the admin distribution) and use its HTTPS URL as RUSTFS_PUBLIC_URL — that is the URL browsers fetch photos from, so it must be HTTPS and CORS-readable by the web app origin.

Step 2: EC2 instance

  1. Instance. us-east-1 (or the region nearest your users; this page quotes us-east-1 prices). t3.medium (4 GB) for Tier 1, t3.xlarge (16 GB) with the hosted-layers profile (GeoServer runs with a 2 GB Java heap). t3.large fits Tier 2 only without the sidecar and with the GeoServer heap reduced to 1.5 GB. Everything is containerized — Bun, Python, and Java are not needed on the host.

  2. AMI and user data. Amazon Linux 2023 with this user data:

    #!/bin/bash
    dnf install -y docker git docker-compose-plugin
    systemctl enable --now docker
    usermod -aG docker ec2-user
  3. Storage. 30 GB gp3 for Tier 1, 50 GB for Tier 2. The admin service stores its SQLite database and project/thumbnail objects in the management-data volume. Snapshot that volume on a schedule — a backup that captures only the database or only objects/ leaves the inventory inconsistent.

  4. Security group. Allow inbound 8787, 8788, and 8790 from the CloudFront origin-facing managed prefix list (com.amazonaws.global.cloudfront.origin-facing) only, plus SSH from your IP. TLS terminates at CloudFront (ACM, free); the EC2 origins stay plain HTTP on that restricted path.

  5. Network. Public subnet, no NAT gateway needed, no ALB — CloudFront → EC2 over HTTP with the security group doing the access control. An ALB adds ~$16/mo and LCU charges for nothing CloudFront does not already do.

  6. DNS. Route 53 hosted zone, alias A/AAAA records to the distributions. The ACM certificate CloudFront uses must live in us-east-1.

Step 3: Compose override for AWS

The stock compose file binds every service to 127.0.0.1, which is correct for a local host but unreachable from CloudFront. Add a compose.override.yaml next to it (Compose merges it automatically and replaces list-valued keys such as ports):

services:
  admin:
    ports: ["8787:8787"]
  edge:
    ports: ["8788:8788"]

Keep ADMIN_TRUST_PROXY=true (Step 4) so view-deduplication reads CF-Connecting-IP/X-Forwarded-For (CloudFront sets both) instead of the CloudFront edge IP.

Step 4: Deploy the Tier 1 stack

Create the environment file (Compose reads .env in the project root):

# --- admin ---
APP_ORIGIN=https://admin.mappview.example.com
VIEWER_URL=https://mappview.example.com/
CORS_ORIGINS=https://mappview.example.com,https://admin.mappview.example.com,https://mappview.example.com/tauri,tauri://localhost,http://tauri.localhost
ADMIN_TRUST_PROXY=true
ADMIN_BIND_ADDRESS=0.0.0.0
ADMIN_LOCAL_TOKEN=
ADMIN_BOOTSTRAP_TOKEN=

CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
CLERK_AUTHORIZED_PARTIES=https://mappview.example.com,https://admin.mappview.example.com,https://mappview.example.com/tauri,tauri://localhost,http://tauri.localhost

# --- S3 instead of rustfs ---
RUSTFS_ENDPOINT=https://s3.us-east-1.amazonaws.com
RUSTFS_PUBLIC_URL=https://photos.mappview.example.com
RUSTFS_ACCESS_KEY=AKIA...
RUSTFS_SECRET_KEY=...
RUSTFS_BUCKET=mappview-field-photos
RUSTFS_REGION=us-east-1

# --- edge ---
EDGE_ALLOWED_ORIGINS=https://mappview.example.com,https://admin.mappview.example.com
AI_PROVIDERS=...
EDGE_ALLOWED_MODELS=...
EDGE_DEFAULT_MODEL=...
EDGE_RATE_LIMIT=10
MAPPVIEW_AI_PROXY_TOKEN=...

Notes on the values:

  • RUSTFS_ENDPOINT must remain an http(s) URL; the client uses path-style addressing, which AWS S3 still accepts — re-verify after SDK bumps.
  • CORS_ORIGINS must include the Vercel origin, the viewer origin, and — for desktop Share — tauri://localhost and http://tauri.localhost.
  • Clerk keys compile into the browser bundle at build time, so rebuild images after changing them.

Then deploy:

git clone https://github.com/your-org/mappview.git && cd mappview
docker compose -f docker/compose/admin.yaml up -d --build admin edge edge-redis mappview

# web app + sidecar in one image (root Dockerfile): used only for /sidecar/*
docker build -t mappview-web .
docker run -d --name mappview-web --restart unless-stopped \
  -p 8790:80 \
  -e MAPPVIEW_SIDECAR_TOKEN="$(openssl rand -hex 24)" \
  -v /data/conversion:/data \
  mappview-web
  • The mappview-web container’s nginx is the trusted caller of the sidecar: it injects the X-MappView-Token header and forwards a loopback Host, which the sidecar’s TrustedHostMiddleware requires. Put CloudFront in front of this nginx (Step 5), not the bare uvicorn.
  • -v /data/conversion:/data gives the conversion tools their work directory; the image confines reads/writes to MAPPVIEW_CONVERSION_ROOTS (default /data) because the sidecar is reachable same-origin. If you use the sidecar’s PostGIS endpoints, set MAPPVIEW_POSTGIS_HOSTS to the allowed host:port entries — it refuses every destination until then.
  • Save the generated MAPPVIEW_SIDECAR_TOKEN; Step 5 needs it.

The Vercel web app reaches the sidecar same-origin, exactly as the nginx browser build does. Add a rewrite so /sidecar/* on the Vercel origin is proxied to the admin distribution:

{
  "rewrites": [
    {
      "source": "/sidecar/:path*",
      "destination": "https://admin.mappview.example.com/sidecar/:path*"
    }
  ]
}

Remote-service proxy routes

Route /__mappview_wfs_proxy, /__mappview_wms_proxy, /__mappview_wmts_proxy, /__mappview_gpx_proxy, and /__mappview_raster_proxy on the web origin to the same path and query on services/edge. For Vercel, add one rewrite per route, for example:

{
  "source": "/__mappview_wfs_proxy",
  "destination": "https://tiles.mappview.example.com/__mappview_wfs_proxy"
}

Include the web origin in EDGE_ALLOWED_ORIGINS. Forward Origin, Referer, Sec-Fetch-Site, Range, X-MappView-Forward-Headers, X-MappView-Service-Authorization, and the service headers named by X-MappView-Forward-Headers. Remote Authorization travels in X-MappView-Service-Authorization, leaving the host login intact. Disable caching for these five routes, including their query strings. Upstream error statuses and bodies must reach the browser diagnostics unchanged.

Docker nginx and the publication viewer host already forward these routes to MAPPVIEW_EDGE_URL. Vite development and preview use the identical handler directly. It rejects private targets and unsafe redirects, pins DNS, limits responses to 50 MiB, and bounds the full request to 30 seconds. Desktop and notebook bundles use their existing transport instead.

Step 5: CloudFront distributions

Two distributions, one per public origin (CloudFront routes alternate domain names per distribution, not per path). Create the ACM certificate in us-east-1, covering both names.

admin.mappview.… distributiontiles.mappview.… distribution
Origin 1EC2 : 8787 (HTTP)EC2 : 8788 (HTTP)
Default behavior (*)Cache policy CachingDisabled + origin request policy AllViewerExceptHostHeader (Authorization, Cookie, and client-IP headers must reach the Share API)Cache policy CachingOptimized (tiles are static artifacts)
/sidecar/* behaviorOrigin: EC2 : 8790 (HTTP), custom origin header X-MappView-Token = the value from Step 4, cache policy CachingDisabled—
/__mappview_*_proxy behaviorsNoneCache policy CachingDisabled, forward query strings and the service headers listed above
Viewer protocolRedirect HTTP to HTTPSRedirect HTTP to HTTPS
CompressionOnOn

All three EC2 origins use HTTP only — the security group restricts those ports to the CloudFront prefix list, which is the access control. Add the Route 53 alias records, then verify:

curl -s https://admin.mappview.example.com/api/health
curl -s https://tiles.mappview.example.com/healthz
curl -s https://mappview.example.com/sidecar/conversion/status

Finally, point the photo distribution’s RUSTFS_PUBLIC_URL (Step 1) at its CloudFront HTTPS URL.

Step 6: Tier 2 — hosted layers

Follow Deploy hosted layers for the full topology and production requirements. On AWS two things change:

  • Database. Replace the geonode-db container with RDS for PostgreSQL (db.t4g.small is enough to start; PostGIS is supported on RDS). Create a security group allowing 5432 from the EC2 instance only, and override the DATABASE_HOST, DATABASE_URL, and GEODATABASE_URL values for the geonode services in the compose override — they are hard-set in docker/compose/admin.yaml, so an override entry per service is the clean path. Multi-AZ doubles the database cost (~+$23/mo); single-AZ is fine to start.
  • Memory. GeoServer (-Xmx2G), GeoNode/Django, celery, the edge services, and the sidecar image all share the instance — t3.xlarge is the floor; t3.large only works if you drop the sidecar container and shrink the GeoServer heap to 1.5 GB.

Bring the profile up with the same override file:

docker compose -f docker/compose/admin.yaml --profile hosted-layers up -d --build

Cost estimate (us-east-1, on-demand, single AZ)

ItemTier 1Tier 2
EC2 (t3.medium / t3.xlarge)~$30~$121
EBS gp3 (30 / 50 GB)~$2.4~$4
RDS db.t4g.small PostGIS + 20 GB—~$26
S3 + CloudFront + Route 53 + public IPv4~$5~$7
Total~$40/mo~$160/mo

The real scaling variable is egress, not compute. CloudFront includes 1 TB data-out per month free (then ~$0.085/GB); raw EC2 egress is only 100 GB free then $0.09/GB, which is why every bandwidth-heavy origin goes through CloudFront. Tile traffic is roughly 10–50 MB per map session, so the free 1 TB covers on the order of 20,000–100,000 sessions per month. The larger items to watch are hosted-layer downloads (up to 2 GB each, MAX_LAYER_BYTES) and GeoServer WMS/WMTS responses; GeoServer’s GWC disk cache on EBS keeps repeated tile requests cheap.

Operations

  • Updates. git pull, rebuild the changed images, and re-run the same docker compose up -d commands. All VITE_* and Clerk publishable values are build-time — changing them requires rebuilding the images and the Vercel deployment together.
  • Backups. EBS snapshots of the management-data volume (database + objects/ must be captured together) and S3 versioning on the photo bucket. In Tier 2, RDS automated backups cover the PostGIS data.
  • Monitoring. CloudWatch basic instance metrics are free. The repository’s gatus/Grafana/Prometheus stack runs fine as additional containers on the same instance if you want uptime checks and dashboards back.
  • Secrets. Move the .env values into AWS Secrets Manager or SSM Parameter Store before the deployment grows past one operator; the compose file intentionally uses plain environment values for local testing only.

Capabilities to expect

Always available (Tier 1)

  • Share — project publishing, immutable version history, forks, collections, thumbnails, view counts, and personal mappview_… API tokens verified as SHA-256 hashes. Desktop Share works when the Tauri origins are in CORS_ORIGINS.
  • Embeds and “Open in Meratyn” — the viewer URL passed at build time; HTML exports and <iframe> embeds point at the Vercel app.
  • Tile proxies — OpenAerialMap, Source Cooperative, and Planetary Computer sources served from tiles.mappview.… so the browser never needs per-provider CORS or tokens.
  • AI proxy — provider-pluggable assistant traffic flows through edge with allowlisted models, a rate limit, and upstream keys held server-side.
  • Field photos — direct browser-to-S3 upload via presigned POST (25 MB per photo by default, FIELD_PHOTO_MAX_BYTES).
  • Web processing with the sidecar deployed — Conversion tools (GeoParquet, FlatGeobuf, PMTiles, COG), native-file raster workflows, and Whitebox tools outside the browser catalog can use the sidecar; the Vercel rewrite makes it same-origin at /sidecar. Confine it with MAPPVIEW_CONVERSION_ROOTS (default /data) and MAPPVIEW_POSTGIS_HOSTS. Sidecar PMTiles conversion and sidecar Whitebox paths are amd64-only — standard EC2 instances are amd64, so this is not a constraint on AWS (it is on ARM/Apple Silicon). Without the sidecar, browser-supported conversion, raster, and Whitebox operations remain available; native-file paths and operations outside those browser engines need the sidecar.
  • Storage quotas — 25 MB per project document, 100 MB default per-user quota, both configurable through environment.

Added by Tier 2 (hosted layers)

  • Layer publication — upload GIS files through admin; celery publishes them into GeoNode/GeoServer asynchronously.
  • Catalogue sync — admin reconciles the GeoNode catalogue every GEONODE_CATALOGUE_SYNC_INTERVAL_MS (default 5 minutes).
  • OGC delivery — admin fronts stable WMS/WFS/WCS capability and request endpoints for published layers, keeping provider endpoints private and issuing short-lived signed access (LAYER_ACCESS_TTL_SECONDS, 300 s default). These flow through CloudFront like everything else.