Yes, you can. Here is the catch.
This guide is the whole playbook. If you read it end to end and follow it, you will end up with a working, secured, monitored, backed-up production website on your own AWS account for about fifteen dollars a month (as of writing; AWS pricing changes, and your region, traffic, and storage choices will move the number). The site you are reading right now was built the same way.
There are no AI gates, no NDA-protected secrets, no special knowledge required. Every step in here is documented somewhere on the public internet. I am writing it down in one place because the documentation is scattered across forty different blog posts, three AWS docs portals, six Stack Overflow answers (two of them out of date), and whatever Claude or ChatGPT happen to give you that hour.
This guide is free because the steps are not the secret. The value is knowing what to do when a step does not work the way the documentation said it would.
The catch is honest: this is a forty-to-eighty-hour project for somebody with zero experience. It is much faster the second time, like everything else in this kind of work. The hard part is not the steps. The hard part is what happens between the steps. A particular line of output you weren't expecting. A page in your browser that should be loading and isn't. A 502 that wasn't there an hour ago. The DNS change that is “supposed to propagate instantly” and hasn't. A cron job that runs silently for two weeks before you realise it failed every night.
If at any point during this process you find yourself spending four hours on a single error message, that is normal. Take a walk. Come back. The error message is correct; you are just missing one piece of context the author of the tool assumed everybody would have. Half of this work is building up that context the first time.
At the end of the guide there is a short, honest chapter about whether you should be doing this at all. If your hourly value of time is over fifty dollars and writing backend code is not what you actually want to be doing with your life, the math is straightforward: it is cheaper to pay somebody whose full-time job is this. That somebody can be me, or it can be one of the dozens of other capable engineers who do this work. Either way, the guide is here, and it is yours.
AWS account, IAM, billing alarm
Go to aws.amazon.com and create an account. Use an email address you will own for many years. AWS will ask for a credit card. They will put a one-dollar authorisation on it. Within the free tier your monthly bill for this stack runs about twelve to fifteen dollars. If your bill ever crosses fifty dollars without warning, something is wrong; we will set up an alarm for that in a minute.
Turn on MFA on the root account
The email address you just signed up with is now your root account. The root account can do almost anything in the AWS account: change billing settings, close the account, alter security credentials, and create or destroy any infrastructure. Treat it like a break-glass credential, not a daily login. If somebody phishes you out of the password, they have your whole AWS environment.
Go to IAM > Dashboard. There will be a banner saying “Enable MFA on your root user.” Do that now. Use an authenticator app (Aegis, 1Password, Authy). Do not use SMS; sim-swap attacks are real.
Create an IAM user for day-to-day work
The whole point of having a separate IAM user is that you do not log in as root for ordinary work. Create one user, give it AdministratorAccess (you can tighten this later when you know what you actually need), enable MFA on it too, and from now on use that login for everything.
1. IAM > Users > Create user - User name: yourname-admin - Provide console access: yes - Console password: autogenerated, store in your password manager - Require password reset on first login: no 2. Attach permissions policy: AdministratorAccess 3. After creation: IAM > Users > yourname-admin > Security credentials - Enable MFA (authenticator app, not SMS) 4. Bookmark the IAM sign-in URL it shows you on the user page: https://<account-id>.signin.aws.amazon.com/console
Set a billing alarm
Set this once and forget it. If anything goes wrong (a forgotten EC2 instance, a misconfigured Lambda in a recursive loop, an accidental NAT gateway), you find out from this alarm and not from a $4,000 bill.
1. Billing > Billing Preferences > turn on "Receive Billing Alerts"
2. CloudWatch > Alarms > Billing > Create alarm
- Region: us-east-1 (billing metrics live here regardless of
where your actual resources run)
- Metric: Total Estimated Charge, USD
- Threshold: > 30 USD (pick a number 2x your expected bill)
- SNS topic: create one called "billing-alerts", subscribe
your email
- Confirm the email Amazon sends you to the SNS topicLaunch an EC2 instance
EC2 is “a Linux server in the cloud you rent by the hour.” That is the entire model. We are going to rent a small one, install everything on it ourselves, and learn what is actually on it. This is the part where most tutorials skip ahead to a managed service; we are not going to.
Pick a region
Regions are physically-different AWS data centres. The instance you launch in us-east-2 (Ohio) cannot talk to a database you put in us-west-1 (N. California) without going over the public internet. Pick one region and put everything there. us-east-2 is a fine default; it's slightly cheaper than us-east-1 and runs newer hardware.
Launch the instance
Name and tags: Name: penumbra-prod (or whatever your project is called) Application and OS Images: Ubuntu Server 24.04 LTS (HVM), 64-bit (x86) This is free-tier eligible. Instance type: t3.micro (1 GB RAM, 2 vCPU burst, $7.59/mo on-demand) This is also free-tier eligible for your first 12 months. Key pair (login): Create new key pair - Name: penumbra-prod - Type: ED25519 - Format: .pem - DOWNLOAD THE FILE. You cannot re-download it later. - chmod 400 ~/.ssh/penumbra-prod.pem on your local machine. Network settings: Create security group - Name: penumbra-prod-sg - Allow SSH (22) from My IP - Allow HTTP (80) from Anywhere (0.0.0.0/0) - Allow HTTPS (443) from Anywhere (0.0.0.0/0) Configure storage: 20 GiB gp3 root volume (8 GiB will fight you when npm starts installing things) Launch.
Allocate an Elastic IP
Without an Elastic IP, your instance's public IP changes every time you stop and start it. That breaks DNS, breaks SSL, breaks everything that knows the old IP. Elastic IPs are free as long as they are attached to a running instance, and cost about four dollars a month if you orphan one. Allocate one and attach it to the instance you just launched.
SSH in for the first time
ssh -i ~/.ssh/penumbra-prod.pem ubuntu@<your-elastic-ip> # First connection asks "are you sure" — type yes. # You should land at: ubuntu@ip-xx-xx-xx-xx:~$
First server setup
You are now SSHed into a fresh Ubuntu. The first thing to do is the boring thing: bring it up to date and lock the outside doors.
Update everything
sudo apt update sudo apt upgrade -y sudo apt install -y build-essential curl git
This will take a few minutes on a fresh box. Some of the upgrades will require a kernel reboot. If apt prints “A reboot is required,” do it now: sudo reboot. Wait sixty seconds, SSH back in.
Enable the firewall
The EC2 security group already restricts traffic at the AWS level. ufw adds a second layer at the OS level, which matters because it's the layer your own services interact with.
Be more specific with port 22 than the world. Opening SSH to 0.0.0.0/0 means every bot scanning the internet for port 22 will hammer your box constantly. Key-only auth keeps them out, but the noise still costs CPU and fills logs. Restrict to your home/office IP and re-add new ones as needed.
# Replace YOUR_PUBLIC_IP with the IP your laptop currently has # (visit https://ifconfig.me to see it). Re-run with new IPs when # your address changes. sudo ufw allow from YOUR_PUBLIC_IP to any port 22 sudo ufw allow 80 sudo ufw allow 443 sudo ufw --force enable sudo ufw status
If your IP changes too often to keep up, leave the restriction on the AWS security group side (which has the same effect) or look into a bastion-host or VPN setup later. For a beginner project, at minimum understand that opening port 22 to the world means bots will hit it constantly.
Turn on unattended security upgrades
You do not want to wake up to the news that your server has been compromised because of a kernel CVE that was patched two months ago and you never installed. Unattended-upgrades installs security patches automatically.
sudo apt install -y unattended-upgrades sudo dpkg-reconfigure -plow unattended-upgrades # When asked: yes, automatically install stable updates.
Disable SSH password auth
You already have key-only access (you SSHed in with the .pem file). Verify password authentication is actually off — many cloud images already disable it via cloud-init, but do not assume. Check the effective sshd config:
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin|pubkeyauthentication' # Expected: # passwordauthentication no # permitrootlogin no # pubkeyauthentication yes # # If any of those are wrong, edit the config explicitly:
sudo nano /etc/ssh/sshd_config # Find these lines and set them to: PasswordAuthentication no PermitRootLogin no PubkeyAuthentication yes # Save (Ctrl-O, enter, Ctrl-X), then: sudo systemctl restart ssh
Do not close your existing SSH session before you test that a new SSH session still works. If you made a typo, the new session will fail to connect, and you still have the old session to fix it from.
Swap and the t3.micro reality
A t3.micro has one gigabyte of RAM. That is enough to run nginx, Node, MySQL, and PM2 in steady state. It is not enough to run npm install on a non-trivial React project, or vite build on anything past a few dozen modules. Those processes will be killed by the Linux OOM killer mid-run, and the only error you will see is “Killed” on the line after a long pause.
The fix is a swap file. A two-gigabyte swap turns the OOM kills into “your build takes ninety seconds instead of twenty.” That is the right trade on a t3.micro.
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile # Make it permanent (survives reboots): echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab # Verify: sudo swapon --show free -h
MySQL
We will install MySQL 8 from the Ubuntu repos, lock down the root user, then create a single non-root user with privileges only on the application database. This is standard least-privilege practice; do not be tempted to ship the root password as your app credential.
Install
sudo apt install -y mysql-server sudo systemctl status mysql # should be active (running)
Secure the install
sudo mysql_secure_installation # Walk through the prompts: # - VALIDATE PASSWORD COMPONENT? optional. If you skip it, you are # responsible for picking a strong password yourself. Generate a # long random one in your password manager rather than hand-typing # clever special-character passwords; copy-paste the exact value # and quote it properly wherever it ends up (.env, my.cnf, etc.). # - Set root password: paste the random password from your password manager # - Remove anonymous users: yes # - Disallow root remote login: yes # - Remove test database: yes # - Reload privilege tables: yes
Create the app database + app user
sudo mysql # enter as root via socket CREATE DATABASE hello_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'hello_user'@'localhost' IDENTIFIED BY 'GENERATE_A_LONG_RANDOM_PASSWORD_HERE'; GRANT ALL PRIVILEGES ON hello_app.* TO 'hello_user'@'localhost'; FLUSH PRIVILEGES; EXIT; # Test: mysql -u hello_user -p hello_app -e "SELECT 'ok';"
Node, nginx, PM2
Three pieces:
- Node.js: the runtime your backend lives in.
- nginx: the web server that terminates HTTPS, serves your static frontend, and reverse-proxies API requests to Node.
- PM2: the process manager that keeps Node running, restarts it on crash, and rotates its logs.
Node (from NodeSource, not apt)
The Node in Ubuntu's default repos is old enough to break modern tooling. Use the current LTS line from NodeSource. At the time this site was deployed, production was running Node 22 from NodeSource; substitute whichever LTS version is current when you read this.
# Replace 22 with the current LTS major if it has changed. curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt install -y nodejs node --version # should be v22.x (or the current LTS) npm --version
nginx
sudo apt install -y nginx sudo systemctl status nginx # Visit http://<your-elastic-ip> — should see the default nginx page.
PM2
sudo npm install -g pm2 # Make PM2 launch on boot, as your current user: pm2 startup systemd -u $USER --hp $HOME # It will print a sudo command. Copy and run it. # Later, after you start your app for the first time: # pm2 start ./server.js --name hello-backend # pm2 save # persists the process list across reboots
DNS and Route 53
You have a public IP. You need a domain name pointing at it. The cleanest path is to register the domain through Route 53 (AWS's DNS service); the records, the registrar, and the billing all live in one place.
Register a domain
Route 53 > Registered domains > Register domain. Standard .com registrations are around twelve to fifteen dollars a year. Privacy protection is included by default. Registration takes a few minutes to a few hours to propagate; you cannot do the next step until the hosted zone is created (which Route 53 does automatically).
Create A records pointing at your EC2 IP
Record 1 (apex): Record name: (leave blank) Record type: A Value: <your-elastic-ip> TTL: 300 Record 2 (www): Record name: www Record type: A Value: <your-elastic-ip> TTL: 300
Verify DNS
# From your local machine: nslookup yourdomain.com nslookup www.yourdomain.com # Both should return your Elastic IP. Route 53 changes are usually # visible within seconds to a few minutes. If you still don't see # the right answer after several minutes, check that you edited the # right hosted zone, that the domain actually uses those nameservers, # and that you didn't accidentally create the record under the wrong # name.
Let’s Encrypt and HTTPS
Free TLS certificates, with auto-renewal, in two commands. Let's Encrypt is the open certificate authority that broke the “HTTPS costs money and is painful to renew” status quo a decade ago. Certbot is the tool that talks to them.
sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx \ -d yourdomain.com \ -d www.yourdomain.com # It will: # 1. ask for an email (for renewal failure notifications) # 2. agree to terms of service # 3. validate that you control the domain by serving a file from # your nginx on port 80 (this is why DNS had to be live first) # 4. issue the cert, place it in /etc/letsencrypt/live/... # 5. edit your nginx config to load the cert and redirect HTTP to HTTPS # 6. reload nginx # Verify auto-renew is installed: sudo systemctl status certbot.timer # Manual dry run of renewal (to confirm everything works): sudo certbot renew --dry-run
Now visit https://yourdomain.com and you should see the default nginx page over HTTPS with a valid green lock in the browser. We are about to replace that default page, but the cert and the redirect are now in place regardless.
The Express backend patterns that matter
This chapter is not a tutorial on Express. It is a list of the specific patterns that matter in production and that most quick-start tutorials skip. Adopt them on day one; they cost almost nothing to add early and a lot to retrofit later.
Sessions with a MySQL store
Default express-session uses in-memory storage. That works for development and fails the first time PM2 restarts the process and every logged-in user loses their session. Back it with MySQL using express-mysql-session.
bcrypt cost 12 + timing-constant login
Hash passwords with bcrypt, cost factor 12. On a t3.micro that is about 250ms per hash, which is the right tradeoff between user-facing latency and attacker cost.
Then a subtle thing: when somebody tries to log in with an email that does not exist, do not skip the bcrypt comparison. If you do, your login route returns in 5ms for a non-existent user and 250ms for an existing one, and an attacker can enumerate your user list by timing alone. The fix is to always run the comparison, against a fake hash if necessary.
const FAKE_PASSWORD_HASH = bcrypt.hashSync('placeholder-for-timing', 12);
const user = await findUserByEmail(email);
const hashToCompare = user ? user.password_hash : FAKE_PASSWORD_HASH;
const ok = await bcrypt.compare(submittedPassword, hashToCompare);
if (!user || !ok) {
// Same response either way. Same timing either way.
return res.status(401).json({ error: 'Invalid credentials' });
}Rate limiting
express-rate-limit, multiple tiers. A loose tier on /api/* (100 req/min/IP) as a global safety net, a strict tier on auth mutations (10 req/15-min/IP on login, register, forgot-password), and a separate tight tier on the contact form (5 req/hour/IP) so bots cannot flood your inbox.
The Stripe webhook trap
If you ever take payments via Stripe, this single thing will burn three hours of your life if you have not heard it before. Stripe signs webhooks with HMAC over the raw request bytes. If express.json() parses the body before your webhook handler runs, the bytes change (whitespace, key order), and signature verification silently fails forever.
// Stripe webhook MUST be mounted BEFORE express.json():
app.post(
'/api/payments/webhook',
express.raw({ type: 'application/json' }),
paymentsWebhookHandler
);
// THEN the global JSON parser:
app.use(express.json());Global error handler + uncaught exception handlers
One unhandled promise rejection is enough to crash a Node process. Without a handler, you get a silent process exit and PM2 restarts you, but every in-flight request fails. Wire all three:
// Last middleware: catches errors thrown synchronously or passed to next(err)
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Internal server error' });
});
// Process-level catches for the things Express never sees:
process.on('uncaughtException', (err) => {
console.error('uncaughtException:', err);
process.exit(1); // clean exit so PM2 restarts cleanly
});
process.on('unhandledRejection', (reason) => {
console.error('unhandledRejection:', reason);
process.exit(1);
});Fail-fast on missing secrets
The process.env.SESSION_SECRET || 'dev-default' fallback you see in every Express tutorial is fine in dev and dangerous in prod. If .env is misplaced or the env var isn't loaded, the app cheerfully boots with a hardcoded secret that is public on GitHub. Refuse to start instead.
if (process.env.NODE_ENV === 'production') {
if (!process.env.SESSION_SECRET || /change-me|insecure|example/i.test(process.env.SESSION_SECRET)) {
console.error('FATAL: SESSION_SECRET missing or example value in production');
process.exit(1);
}
if (process.env.COOKIE_SECURE !== 'true') {
console.error('FATAL: COOKIE_SECURE must be "true" in production');
process.exit(1);
}
}An /api/health endpoint
UptimeRobot (chapter 12) needs something to ping. Give it a tiny endpoint that confirms the worker can talk to the database.
app.get('/api/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', uptime: process.uptime() });
} catch (err) {
res.status(503).json({ status: 'degraded', reason: 'db_unreachable' });
}
});The React frontend and the deploy script
Frontend choice: React + Vite + React Router. Vite is fast, React Router is the de-facto SPA router, both are well-documented. The build output is a static dist/ directory you copy onto the server.
Scaffold
npm create vite@latest frontend -- --template react cd frontend npm install npm install react-router-dom
Code splitting from day one
The default Vite setup statically imports every route into one bundle. A marketing visitor downloads your entire admin dashboard, your Stripe Elements SDK, and code they will never see. Use React.lazy and a Suspense boundary in your layout instead.
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home.jsx'));
const Login = lazy(() => import('./pages/Login.jsx'));
const Admin = lazy(() => import('./pages/Admin.jsx'));
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>Deploy script
On the server, build into dist/, then copy into /var/www/your-app (where nginx looks for static files).
#!/usr/bin/env bash set -euo pipefail cd /home/ubuntu/your-repo/frontend npm install npm run build sudo mkdir -p /var/www/your-app sudo rm -rf /var/www/your-app/* sudo cp -r dist/* /var/www/your-app/ sudo nginx -t sudo systemctl reload nginx echo "Frontend deployed."
nginx config for the SPA
The critical line is the try_files directive: when somebody visits /login directly, nginx tries to find /var/www/your-app/login, fails, and falls back to serving index.html so React Router can take over.
server {
listen 443 ssl; # managed by Certbot
server_name yourdomain.com www.yourdomain.com;
root /var/www/your-app;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri /index.html;
}
# Certbot adds the ssl_certificate / ssl_certificate_key lines below
}Security headers, CSP, compression
Five HTTP response headers, plus gzip, plus cache control. Default nginx ships with none of these. Adding them takes twenty minutes and meaningfully changes your site's security posture and performance.
The headers
# Force HTTPS for a year, on the apex and any subdomain.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Stop browsers from MIME-sniffing a response away from its declared type.
add_header X-Content-Type-Options "nosniff" always;
# Refuse to be embedded in an iframe by any origin.
add_header X-Frame-Options "DENY" always;
# Send the full URL as Referer for same-origin only; just the origin
# for cross-origin; nothing for HTTPS-to-HTTP downgrades.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Content Security Policy — the big one. Restrict what scripts, styles,
# fonts, etc. the browser will load. Start strict ('self' for everything)
# and add explicit origins as you discover what your app actually needs.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self' https://api.stripe.com; frame-src 'self' https://js.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;Compression
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript
application/javascript application/json
application/xml image/svg+xml;Cache control
Vite content-hashes your bundle filenames (e.g. index-CU4XbcKP.js). That means every file under /assets/ is safe to cache forever; if the contents change, the hash changes, and the filename changes, so the browser fetches the new one. The HTML shell, on the other hand, has a fixed name and needs to be re-checked every visit so the new bundle hash gets picked up after a deploy.
location ~* ^/assets/.+\.(js|css|woff2?|ttf|otf|eot|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate" always;
}Operations: backups, monitoring, recovery
You are done building. Now the boring work that decides whether you find out about problems from your monitoring or from your customers.
Nightly database backup
mysqldump in a cron job. Gzip the output. Keep the last fourteen days. The total disk impact is a few megabytes a day at small scale.
#!/usr/bin/env bash
set -euo pipefail
# Pull DB creds out of the app's .env to stay in sync with prod.
ENV_FILE="$HOME/your-repo/backend/.env"
DB_USER="$(grep ^DB_USER= "$ENV_FILE" | cut -d= -f2-)"
DB_PASSWORD="$(grep ^DB_PASSWORD= "$ENV_FILE" | cut -d= -f2-)"
DB_NAME="$(grep ^DB_NAME= "$ENV_FILE" | cut -d= -f2-)"
sudo mkdir -p /backups
sudo chown "$USER:$USER" /backups
# Use --defaults-extra-file so the password never appears in 'ps'.
TMP_CNF="$(mktemp)"
trap 'rm -f "$TMP_CNF"' EXIT
cat >"$TMP_CNF" <<EOF
[client]
user=$DB_USER
password=$DB_PASSWORD
EOF
chmod 600 "$TMP_CNF"
mysqldump --defaults-extra-file="$TMP_CNF" \
--single-transaction --quick --routines --triggers \
--skip-lock-tables --set-gtid-purged=OFF --no-tablespaces \
"$DB_NAME" \
| gzip -c > "/backups/${DB_NAME}_$(date +%F).sql.gz"
# Keep 14 days.
find /backups -maxdepth 1 -name "${DB_NAME}_*.sql.gz" -mtime +14 -delete
echo "[$(date -Iseconds)] backup OK"chmod +x backup-db.sh sudo touch /var/log/your-backup.log sudo chown $USER:$USER /var/log/your-backup.log ( crontab -l 2>/dev/null ; echo "0 3 * * * $HOME/backup-db.sh >> /var/log/your-backup.log 2>&1" ) | crontab -
EC2 is in UTC by default. A cron entry of 0 3 * * * runs at 3am UTC, which is the previous evening in most US time zones. That is fine for backups (low-traffic window everywhere). Just know which clock you are scheduling against.
UptimeRobot (free)
Sign up at uptimerobot.com. New monitor > HTTP(s) > URL https://yourdomain.com/api/health > 5 minute interval > alert contact is your email. That is the whole setup.
Point it at /api/health, not the homepage. Your static frontend will keep loading even when the backend is dead, because nginx serves the HTML directly. The whole point of the health endpoint is that it exercises the live application path including the database round-trip. UptimeRobot must hit that path, not the static file.
PM2 log rotation
Without this, PM2's log files grow without bound and eventually fill the disk. One-time install:
pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 7 pm2 set pm2-logrotate:compress true pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
Recovery checklist
Tape this somewhere. The middle of an incident is not when you want to be reading documentation.
- Backend crashed:
pm2 logs hello-backend --lines 100→ fix env or code →pm2 restart hello-backend. - Site returns 502 from nginx: backend is down (see above) or stopped responding (PM2 says online but the process is wedged — restart it).
- Site returns 503 with
{ maintenance: true }: somebody (you) flipped maintenance mode. Toggle back via your admin panel. /api/healthreturns 503db_unreachable: MySQL is down.sudo systemctl status mysql.- Bad migration corrupted data: restore last night's SQL dump into a scratch database, copy the affected table back.
- Whole instance lost: launch new EC2 from your latest EBS snapshot (if you set that up), restore last night's SQL dump on top to catch the day's writes, repoint Elastic IP.
The honest chapter
You did it. Or, if you are reading the PDF and have not done it yet, you are about to. Either way, here is the part the rest of the internet does not tell you.
What this actually took
For somebody new to all of it, this is a forty-to-eighty hour project the first time. Not eighty hours of typing. Eighty hours of typing, waiting for things, reading error messages, googling those error messages, trying a fix, watching it not work, trying a different fix, eventually getting the right one. Most of that time is not skill acquisition you can keep — it is one-time friction with this specific stack on this specific day.
The second time, with the same stack, it is more like ten to fifteen hours. The fifth time it is closer to four. That is normal. It is also why people who do this for a living are not slow even when the work looks identical to you.
What it keeps costing
The hosting bill stays flat at about $15/month. Your own time is the line that compounds. Every two weeks or so, a Node-side library publishes a CVE that you should upgrade past. Every couple of months, a dependency's major version bump breaks something non-obvious. Every six months or so, Ubuntu's unattended-upgrades will install a kernel update that requires a reboot, which will surface a thing you forgot to make persistent. Once a year, your TLS cert renews automatically — usually. The one time it does not, you find out from UptimeRobot at midnight.
None of this is a reason to be afraid of the work. It is the work. The reason to know about it is so you can budget your time honestly: this is a few hours a month of background-process maintenance once it's built. If you do not have a few hours a month, the maintenance piles up, and the day you actually need to ship a feature you spend the first three of those hours catching back up.
When the AI helps and when it does not
Every step in this guide can be done with an LLM at your side. Claude, ChatGPT, Gemini, whichever. They are excellent at writing the nginx config, generating the backup script, explaining what an error message means, and remembering syntax you forgot. Use them.
What they are not good at, and what I have seen burn days of beginner time, is anything that needs you to actually look at the real running thing: the page in your browser, the live log file, the actual network request. An LLM will tell you confidently that your config is correct because the config it can see is correct; meanwhile, you have a typo in a totally different file that nobody looked at. The verification that matters is you, with your hands on the system. The model is leverage, but you are the part that knows what “done” means.
There is a longer version of this argument on the judgement page of this site with concrete examples. Worth reading if you are about to spend a weekend in the deep end with an LLM.
When to hire someone instead
Honest math, no marketing: if your time is worth more than fifty dollars an hour and software infrastructure is not your craft, paying somebody whose full-time job is this is cheaper than your own time. A typical small build engagement is between ten and twenty-five thousand dollars, four to six weeks, plus an AWS bill you own. Compared to forty to eighty hours of your own learning curve plus the ongoing maintenance burden, the trade gets favourable quickly.
The other reason to hire someone is that on day one of an outage at 2am, the question you want to ask yourself is not “okay where did I save my SSH key?” The whole point of paying for expertise is that there is somebody on the other end of an email who already knows your stack and can be useful in five minutes instead of two hours.
If you have read this far and decided it is worth it regardless: go build it. The guide is yours. Come back if you ever want to skip the maintenance tax. If you have read this far and decided you want help, the calendar link below is the next step.
Whether you build it or hire it, you understand the work now.
If you want a 30-minute conversation about either path — building it yourself with sanity-saving advice, or having me build it for you on a fixed-scope sprint — book a slot. The call is genuinely free of pitch.