Migrate AWS → Sprintsail Runtime
This is the workflow Sprintsail is built around. Take an app you've deployed to AWS, run sail migrate, and the same app comes up on a Kubernetes runtime — Contour ingress, cert-manager TLS, a sealed Secret, CloudNativePG — with the same data and the identical handler code.
This walkthrough is verbatim what was shipped: see commit 49848e7 and the sail-poc example.
What you'll end up with
| Side | Reality |
|---|---|
| AWS | RDS Postgres (4 rows), Secrets Manager api-key, ECS Express WebApp |
| Sprintsail Runtime (K3s on EC2) | Contour ingress with TLS via sail-issuer, CNPG Postgres with the same 4 rows, SealedSecret with the same api-key value, WebApp built+pushed to ECR |
A curl to the runtime URL returns the migrated data — proof that the SAME src/server.ts runs on either target.
Prerequisites
-
The app from Quickstart deployed to AWS with seed data.
-
A Sprintsail Runtime cluster. The cheapest real one: K3s on a single EC2 instance (~$60/mo running, ~$3/mo stopped). Recipe:
# Launch a t3.large EC2 (8 GB RAM is the minimum for the full operator stack)
# Open ports 22 (your IP), 80 + 443 (world), 6443 (your IP)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC='--disable traefik --tls-san <node-ip>' sh -
sudo cat /etc/rancher/k3s/k3s.yaml # pull this to your laptop, rewrite 127.0.0.1 → <node-ip>Then run the operator bootstrap:
cd sprintsail/runtime-bootstrap
./install.sh sail-runtime-k3sSee Sprintsail Runtime target for the full setup.
-
ECR registry wired so the SDK's image push lands somewhere the K3s node can pull from:
aws ecr create-repository --repository-name sprintsail/orders-web
aws ecr get-login-password | docker login --username AWS \
--password-stdin <account>.dkr.ecr.us-east-1.amazonaws.com
./refresh-ecr-auth.sh ubuntu@<node-ip> ~/.ssh/<your-key>.pem # writes /etc/rancher/k3s/registries.yaml
The migrate command
From your project root:
SAIL_IMAGE_REGISTRY=<account>.dkr.ecr.us-east-1.amazonaws.com \
SAIL_INGRESS_DOMAIN=<node-ip-with-dashes>.sslip.io \
npx sail migrate \
--from aws:us-east-1 \
--to sprintsail-runtime:sail-runtime-k3s \
--yes
What you'll see:
Migration plan: aws:us-east-1 → sprintsail-runtime:sail-runtime-k3s
Project: orders
✓ database orders
✓ secret api-key
✓ webapp web
Provisioning equivalents on destination...
[k8s/database] created Cluster sail-orders/orders-orders (postgres 16, 1 instance, 1Gi)
[k8s/secret] created SealedSecret sail-orders/orders-api-key (encrypted at rest)
[k8s/webapp] preparing sail-orders/orders-web
[k8s/image] docker build (linux/amd64) <ecr>/sprintsail/orders-web:...
[k8s/image] docker push <ecr>/sprintsail/orders-web:...
[k8s/webapp] created Deployment sail-orders/orders-web
[k8s/webapp] TLS via cert-manager issuer "sail-issuer"
[k8s/webapp] created Ingress sail-orders/orders-web
Copying data...
[k8s/migrate] opened RDS SG sg-c68d33fe to cluster egress <node-ip>/32:5432
[k8s/migrate] running pg_dump|psql Job sail-orders/sail-dbmig-orders-orders-rw-... (AWS RDS orders-orders.<rds-host> → orders-orders-rw.sail-orders.svc.cluster.local)
[k8s/migrate] database copied AWS RDS orders-orders.<rds-host> → orders-orders-rw.sail-orders.svc.cluster.local/app
[k8s/migrate] secret value copied aws:us-east-1/orders/api-key → sail-orders/orders-api-key
Migration complete.
Then hit the runtime URL:
curl -sk https://orders-web.<node-ip-with-dashes>.sslip.io/
You get back the same JSON the AWS WebApp served — same rows, same api-key prefix — but it's coming from CNPG on Kubernetes, with a sealed Secret, behind cert-manager TLS.
How the data copy works
This is the bit AWS punts on (its migrateData() for Database is a manual stub). The runtime provider automates it with a Kubernetes Job.
-
Auto-open the source SG. The provider reads
securityGroupIdfrom the source RDS attributes (set by the AWS provider on provision) and authorizes 5432 inbound from the destination cluster's external egress IP. Idempotent. For the same-VPC case (intra-VPC traffic uses the private IP, which a CIDR rule can't cover), the failure message includes the exact SG-to-SGauthorize-security-group-ingresscommand. -
Resolve source creds. The provider calls
GetSecretValueCommandon the AWS Secrets Manager secret named in the RDS attributes. Buildspostgresql://<user>:<pass>@<rds>:5432/postgres. -
Spawn an in-cluster Job. Image is the CNPG-tagged Postgres so
pg_dumpis the right version. The Job runs:set -e
pg_dump --clean --if-exists --no-owner --no-privileges -f /tmp/dump.sql "$SRC_URL"
psql -v ON_ERROR_STOP=1 -f /tmp/dump.sql "$DST_URL"File +
&&rather than a pipe —sh/dashhas nopipefail, so a pipedpg_dump | psqlwould mask a dump failure (psql gets empty input, exits 0, Job falsely succeeds). Caught during the live POC; permanent fix. -
Secret value copy. AWS
GetSecretValueCommand→ read-modify-replace on the destination materialized Secret (the K8s client'sPATCHdefaults to JSON-Patch, hence replace not patch).
The cutover script
After migration, .sail/cutover-<timestamp>.sh lists every URL/endpoint that changed:
Resource: orders.database.orders
endpoint: orders-orders.<rds-host>
→ orders-orders-rw.sail-orders.svc.cluster.local
Resource: orders.webapp.web
url: https://sa-<id>.ecs.us-east-1.on.aws
→ https://orders-web.<node-ip>.sslip.io
Use this as the checklist for downstream consumers (DNS records, webhook URLs, etc.) before running sail destroy --target aws:us-east-1.
Gotchas the POC surfaced
- Image architecture. Apple Silicon builds arm64 by default; cloud K8s nodes are amd64. The provider builds
linux/amd64when pushing to a registry (vs. kind-load, which preserves host arch). Override withSAIL_IMAGE_PLATFORM=linux/arm64for arm64 clusters (Graviton). - Same-VPC RDS reachability. If the destination cluster is in the same VPC as the source RDS, DNS resolves the RDS endpoint to its private IP, and CIDR-based SG rules don't cover that path. Add an SG-to-SG rule.
- ECR token expiry. 12 hours.
refresh-ecr-auth.shre-issues + restarts K3s. The "real" answer is an IAM instance profile + the kubelet credential provider — v1.1.
What this proves
sail migrate aws → sprintsail-runtime works for real, on real infrastructure, with real data. The headline isn't a kind-cluster demo; it's a Postgres dump from an actual RDS over the internet into an actual CNPG cluster on an EC2 you'd happily run a workload on.
The handler code didn't change. The architectural moat — "we own both halves and we don't lock either" — is structurally sound.