Sscribnet.io
Zero to Dangerous

AWS

Move a real server from a budget hoster to EC2 — same compose file, same data, same name — and learn instances, security groups, cloud-init, and billing by moving something you actually care about.

6 chapters~30 min readBeginner
Chapter 1

The Portable Server: Design for the Exit

The workload in this book is deliberately unglamorous: one uptime monitor — Uptime Kuma, in the build this book is based on — running on a small VPS at a budget hoster, watching a homelab from outside the house. The job is to move it to AWS EC2. Not because the old hoster failed. As a proof of concept: the migration is the lesson, and the monitor is just important enough that doing it wrong would sting.

Honest framing up front, because AWS deserves it: this move does not save money. The AWS equivalent of a bargain VPS lands a few dollars a month higher, not lower. What you're buying is the skill set — EC2, security groups, EBS, key pairs, user data — learned on a workload where a mistake costs you an afternoon, not a business.

Why the move was boring (by design)

The whole migration kit fits in one directory, and none of it mentions a provider:

Here's the heart of it — the compose file that ran on the old hoster, unchanged on AWS:

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2
    container_name: uptime-kuma
    restart: unless-stopped
    ports:
      # localhost only -- exposure is someone else's job (chapter 5)
      - "127.0.0.1:3001:3001"
    volumes:
      - kuma-data:/app/data

volumes:
  kuma-data:
    name: kuma-data   # explicit name: scripts find it on any host

Two decisions in that file do the heavy lifting. The app binds to 127.0.0.1 only — it never faces a network directly; a separate layer decides who may reach it. And the volume's name is pinned explicitly instead of accepting Compose's auto-generated project-prefixed name, so backup.sh and restore.sh find kuma-data on any machine, in any directory.

The backup itself is one idea: mount the volume read-only into a throwaway container and tar it up.

docker run --rm \
  -v kuma-data:/data:ro \
  -v "$PWD/backups:/backup" \
  alpine:3 tar czf "/backup/kuma-data-$(date +%Y%m%d-%H%M%S).tgz" -C /data .

One analogy, then the principle: this is the difference between moving house and furnishing a hotel room. If your possessions live in labeled boxes (the volume) and the furniture list is written down (the compose file), the moving truck does not care which city it's driving to. The cheapest day to design your exit from a provider is the day you deploy — every shortcut you take on day one ("I'll just configure it in their panel") is a hostage you negotiate with on moving day.

Footgun: a backup that exists only on the box it backs up is not a backup — it's a souvenir. The tarball gets copied off the server before anything else happens (chapter 5). Until that copy exists somewhere else, your rollback is theoretical.
Chapter 2

EC2 Anatomy: Reading the Instance Menu

The EC2 launch wizard is the first boss fight: hundreds of instance types with names like c7gn.2xlarge. The trick is that the name is a compressed spec sheet — once you can read it, the menu stops being intimidating. This migration's pick, decoded:

t4g.small
t  = family: burstable general purpose
4  = generation (newer = faster silicon, usually cheaper per unit of work)
g  = Graviton: AWS's own ARM processors (no letter = Intel, a = AMD)
.small = size step; each step up roughly doubles the RAM

The families worth knowing cold: t burstable general purpose, m balanced general purpose, c compute-heavy, r memory-heavy. For a homelab-scale service, you'll live in t and visit the others when a workload actually demands it.

Nitro: the offload you don't choose

Under nearly every modern instance family sits the Nitro system: networking, storage, and virtualization work is offloaded from the main CPUs onto dedicated Nitro cards. If you've sold datacenter gear, you already know this shape — it's the DPU/SmartNIC idea. The hypervisor tax mostly disappears, the host silicon spends its cycles on your workload, and it's why a two-vCPU cloud instance feels closer to bare metal than it has any right to. You never select Nitro — it comes with the family and generation you pick.

Burstable: honest sizing for lazy workloads

The t family is cheap because it's burstable: each instance is guaranteed a baseline slice of CPU and banks CPU credits while it idles, spending them when load spikes — a capped entitlement with a savings account. An uptime monitor asleep 99% of the time between checks is the perfect burstable tenant; a sustained render farm would be the worst. Match the family to the duty cycle, not to your hopes.

ARM without fear, and storage without regret

The g raises the only real question: will my software run on ARM? For containers, usually yes — a modern image tag points at a manifest list covering several architectures, and the engine pulls the build that matches the host. Verify before migration day, not during:

docker buildx imagetools inspect louislam/uptime-kuma:2
# check the platform list: you want linux/arm64 next to linux/amd64

Storage is an EBS volume — network block storage, in hypervisor terms a VMDK that lives on the storage fabric instead of the host. The default choice, gp3, prices simply: a flat per-GB monthly rate (around $0.08/GB-month) with baseline performance included, so at this scale you're paying for gigabytes, not IOPS. One asymmetry rules every sizing decision: EBS volumes can be grown online, but never shrunk. Choose like you're pouring concrete — 20 GB with room to grow beats 40 GB of ego, and beating the 8 GB default matters because Docker image churn eats small disks. If you undersize, growing is a console edit plus a filesystem extend. If you oversize, shrinking is a rebuild.

Tip: size to what the workload actually uses, not to the old box's spec sheet — the old VPS's 2 GB was a product tier, not a measurement. Check real usage (free -h, df -h) and buy the smallest honest size — for this monitor, t4g.small (2 vCPU, 2 GB).
Chapter 3

The Front Door: Keys, Security Groups, and the Bill

Three things get settled before the launch button gets pressed, because each is roughly ten times more annoying to retrofit afterward: how you'll authenticate, what the firewall allows, and how you'll know what it costs. In that order.

Key pairs: download once, gone forever

EC2 key pairs are asymmetric SSH keys where AWS keeps the public half and hands you the private half — a .pem file — exactly once, at creation. AWS does not retain a copy it can show you later. Lose the file and that door is simply gone. (This is less catastrophic than it sounds if your cloud-init installs your own key for your admin user, chapter 4 — two independent doors is an accident worth engineering on purpose.) Treat the .pem like any other secret: into the password manager, never into git, never into a chat window.

Security groups: a stateful allow-list

A security group is a firewall policy wrapped around the instance. Three properties define it. It's an allow-list only — there are no deny rules; anything not explicitly allowed is dropped. It's stateful — if your box initiates a connection outbound, the replies are allowed back automatically, unlike a stateless ACL where you'd have to write the return rule yourself. And it evaluates before traffic reaches the host, so it protects the box even from its own misconfigurations. For bootstrap, exactly one rule — SSH, from your current public IP, expressed as a /32:

aws ec2 authorize-security-group-ingress \
  --group-id sg-0123456789abcdef0 \
  --protocol tcp --port 22 --cidr 192.0.2.10/32

Read that rule the paranoid way: port 22, one source address on Earth. Not 0.0.0.0/0 "just for setup" — in production and homelabs alike, temporary rules are the most permanent kind.

Footgun: the launch wizard cheerfully offers "Allow HTTPS traffic from the internet" and "Allow HTTP traffic from the internet" checkboxes. For a box whose whole design is tailnet-only access, the only correct state for those boxes is unchecked. Most exposed cloud boxes weren't hacked through genius — they were born exposed by a checkbox somebody meant to revisit.

The bill: IPv4 rent and the tripwire

Two facts about AWS billing surprise everyone exactly once. First: since February 2024, every public IPv4 address costs about $0.005/hour — roughly $3.65 a month — whether it's attached to a running instance or sitting idle. The era of free spare addresses is over; an address is a metered resource, which will matter again in chapter 6. Second: your account type changes the math. Brand-new accounts may sit on a free plan with starter credits — genuinely useful, but read the terms: free-plan accounts can auto-close when the credits or the plan window run out unless upgraded. An account you've held for years is a plain paid account with a card on file and no credits to burn. Either way, open the Billing console and know which one you have before the first launch — it decides whether your POC is effectively free or honestly metered.

Then install the tripwire: an AWS Budget. Set a monthly cost budget slightly above the expected run-rate (expected ≈ $19/month, budget = $20), with email alerts at 80% and 100% of it. Five minutes in the console, and the worst case for a forgotten instance or a pricing misunderstanding becomes an email instead of a statement surprise. A proof of concept without a budget alarm isn't an experiment — it's an open tab.

Chapter 4

cloud-init: The All-or-Nothing Bootstrap

cloud-init is the bootstrap contract between you and any cloud. At launch you hand the provider a block of user data — a YAML file starting with #cloud-config — and on first boot the instance reads it and builds itself: users, packages, files, commands, firewall. It's what turns a VM from a pet you raise by hand into cattle you can recreate from a file in your repo.

Anatomy of the file

#cloud-config
hostname: monitor
manage_etc_hosts: true

users:
  - name: labuser
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ssh-ed25519 TODO_REPLACE_WITH_YOUR_PUBLIC_KEY monitor

package_update: true
package_upgrade: true

packages:
  - ca-certificates
  - curl
  - gnupg
  - ufw
  - jq

runcmd:
  # Docker from the official apt repo (keyring + source lines omitted here)
  - apt-get update -y
  - apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  - usermod -aG docker labuser
  # Tailscale
  - curl -fsSL https://tailscale.com/install.sh | sh
  # Host firewall: deny inbound by default
  - ufw default deny incoming
  - ufw allow 22/tcp
  - ufw allow in on tailscale0
  - ufw --force enable

Read it top-down: identity first (hostname, the admin user with your public key — note this is independent of the EC2 key pair from chapter 3), then packages, then runcmd for everything that's really a shell script in a trench coat: install Docker, install Tailscale, raise the host firewall. When it works, you SSH in a few minutes after launch to a finished machine. Verify by outcomes, not vibes:

id labuser            # the user exists
docker --version      # Docker answers
tailscale version     # Tailscale answers
sudo ufw status       # active, default deny, 22 + tailscale0 allowed
hostname              # says monitor -- remember this one

The launch where nothing happened

First launch of this migration: the instance booted, SSH answered, cloud-init status cheerfully reported done — and none of the config had applied. No labuser. No Docker, no Tailscale. And the tell, hiding in the shell prompt: the hostname was still the provider default, not monitor. cloud-init had finished — it just hadn't run our file.

Diagnosis rule: debug from the ground truth on the box, not the file on your laptop. The instance keeps exactly what it received:

sudo head -25 /var/lib/cloud/instance/user-data.txt

The file was all there — right user, right key, right packages. But two comment lines showed mojibake where em-dashes belonged. The file had been edited and saved on a Windows workstation, and the editor saved it in legacy ANSI encoding instead of UTF-8. The em-dash characters became invalid UTF-8 bytes. cloud-init's YAML loader decodes the entire stream before parsing — one bad byte, even inside a harmless comment, and the whole document is rejected. cloud-init shrugs, falls back to defaults, and still reports done, because from its perspective it did its job: it ran the (empty) config it could read.

The fix was boring on purpose: regenerate the user data as pure ASCII (em-dashes typed as --, no smart quotes, nothing an editor can corrupt), with the real SSH key pre-baked so the launch takes zero hand edits — every hand edit at paste time is a fresh chance to break the file. Then terminate the instance and relaunch from scratch rather than hand-repairing the broken one. A hand-fixed box is a snowflake that proves nothing about your bootstrap; the entire point of the POC was the bootstrap. Ten minutes lost, and the relaunched box passed every checker in the list above.

Footgun: cloud-init status: done means cloud-init finished, not that your config applied. A green status line next to a default hostname is a rejected config until proven otherwise. Verify outcomes — user, hostname, packages — every single time.
Tip: keep user data ASCII-only as a standing rule. It removes an entire failure class — no editor, clipboard, or console paste path can corrupt characters that aren't there.
Chapter 5

Moving Day: Backup, Restore, Rename

Moving day is a sequence, and the order is the safety:

# on the OLD box, inside the project directory
./backup.sh
# on your workstation: pull the tarball down (this copy IS the rollback)
scp labuser@<old-box>:~/homelab/backups/kuma-data-<stamp>.tgz .
# on the NEW box (after chapter 4's bootstrap checks pass)
git clone https://github.com/<your-org>/homelab.git
# push the tarball up into the clone's backups/ folder, then:
cd homelab
./restore.sh backups/kuma-data-<stamp>.tgz
docker compose ps        # service up, bound to 127.0.0.1:3001

The restore script stops the service, empties the named volume, untars your data into it from a throwaway container, and starts the service again. Then comes the only verification that actually matters: open the dashboard and get a login prompt instead of a setup wizard. Log in with the admin credentials from the old box and there it all is — every monitor, every setting, the full heartbeat history, with one gap in the timeline covering the move itself. The gap is the migration, fossilized. This works because chapter 1 did its job: the data never lived in the container, so swapping the entire machine under it is just plumbing.

What does not migrate: Tailscale

Everything about the app moved in a tarball. Nothing about the network identity did, and forcing it to would be a mistake. A Tailscale node's identity lives in a state file (/var/lib/tailscale/tailscaled.state) holding its machine key — deliberately not copied, because two machines claiming one identity on the same tailnet is a conflict, not a shortcut. The new box enrolls as what it is: a new node, with a new tailnet IP, using a pre-authorized key generated in the admin console. (Auth keys are time-boxed — generate yours on moving-day morning, not last week.)

Two consequences follow from that, and both are one-liners once you know them. First, tailscale serve configuration is per-node state, so the command that exposes the app on the tailnet must be re-run on the new box:

sudo tailscale serve --bg http://127.0.0.1:3001

What does not need repeating: the HTTPS approval. Enabling HTTPS certificates is a tailnet-wide setting in the admin console — flip it once, ever, and each new node's certificate is issued automatically. Per-node config re-runs; tailnet-wide settings persist. Knowing which is which saves a confused half-hour.

The rename trick, and its two traps

Your bookmarks, scripts, and muscle memory all point at the node's MagicDNS name — so move the name, not the habits. In the admin console, rename the old node to monitor-old, then give the new node the name monitor. From your workstation, ssh labuser@monitor now lands on AWS. Same name, different continent of infrastructure, zero broken references.

Trap one lives on the old box: renaming a node does not move its serve registration. The old node's serve endpoint stays bound to its previous name, so its dashboard starts answering with an invalid-response error — the node is healthy, the nameplate on its web endpoint is stale. The fix is to re-register serve under the current name:

sudo tailscale serve reset
sudo tailscale serve --bg http://127.0.0.1:3001
tailscale serve status   # should now show the node's CURRENT name

Trap two lives on your machine. The first SSH to the reused name gets you the full-screen warning: REMOTE HOST IDENTIFICATION HAS CHANGED. It is not an attack — it's your own migration looking back at you; the name legitimately points at a new machine with a new host key. But "it's probably fine" is not a verification method. Do it properly: the warning prints the new key's SHA256 fingerprint, and the EC2 console's Get system log for the instance printed the host key fingerprints at first boot. Match them, then clear the stale entry and reconnect:

ssh-keygen -R monitor
Tip: identify nodes by tailnet IP when renaming — console lists can make two same-named Linux boxes look identical. Match the address from tailscale ip -4 on the new box to the machine you're about to rename, and the rename goes to the right machine every time.
Chapter 6

Lock It Down, Kill the Old Box

The instance was born with one public door: the bootstrap SSH rule from chapter 3. That door existed so you could reach the box before Tailscale was up. Tailscale is up — you've been SSHing through it and the dashboard loads over it — so the door has finished its only job. Delete the security group's inbound rule. The group now contains zero inbound rules, and the box's posture changes category: not "well-firewalled," but unreachable.

Verify like you mean it, in a fresh terminal:

ssh labuser@<public-ip>   # expect: hangs, then times out -- nothing answers
ssh labuser@monitor         # over the tailnet: works
# and the monitor's checks are still landing green

Learn to read that timeout: a refusal would mean something received your packet and answered "no." A timeout means silence — the security group dropped it and the internet's scanners get exactly the same nothing. The host's own ufw default-deny (chapter 4) remains as a second layer underneath, so cloud firewall and host firewall agree: nobody knocks, because there's no door.

Why the public IP stays

Your next instinct will be to finish the job — release the public IP entirely. Resist it, because in the default VPC that address is doing double duty: it is also the box's only outbound path. There is no NAT gateway in this network, so every packet the box sends — the monitor's checks, package updates, and Tailscale's own connection to its control plane and relays — leaves through that public IP. Release it and the box doesn't become private; it becomes dark, tailnet included, and your only way back in is the provider console. The AWS-native alternative, a managed NAT gateway, runs roughly $32 a month — about ten times the $3.65 the address itself costs — to solve a problem the empty security group already solved. Exposure is a property of rules, not of addresses: an assigned IP behind zero inbound rules accepts connections from nobody.

Burn-in, then the kill

Both boxes are now running the same monitor independently, and that overlap is the burn-in: a chosen window — 24 to 48 hours covers full day/night check cycles for a workload like this — where the new box has to prove itself while the old one stands by as instant rollback and independent witness. How long is a judgment call about blast radius: a single uptime monitor earns a shorter fuse than a production database would. But make it a decision, written down, not an oversight you rationalize later.

When the window closes: delete the old server in the provider's console, remove the monitor-old node from the Tailscale admin console, and keep the final tarball archived on your workstation. One tail to tie off: deleting the server is not closing the account. Providers typically require every product cancelled and the final invoice — issued on their billing cycle, days later — paid before account deactivation is even offered. Set a reminder for that date now, while you still remember the sequence exists, instead of rediscovering a zombie account next year.

The dangerous checklist