
Migrating an EOL Linux Server to AWS in 8 Hours with Kiro
What started as a favor to a friend became three decades of quiet mentorship — and when that friend's livelihood finally came under threat from a dying server, an AI agent and $20 worth of credits pulled off a migration that would have cost thousands in professional services.
Years ago, when I left the automotive trade for tech, I encouraged one of my coworkers to build a website. Over the years I provided occasional mentoring and tech support, and eventually the website got big and complex enough to need a dedicated server.
At my wedding in 2022, that friend pulled me aside and told me something I wasn't expecting: the website had grown to the point where his wife could stay home and raise their two daughters — and he credited me. I was humbled.
Years later, that server had reached end of life with loads of technical debt and no easy upgrade path. The backup system had died, and RAID-6 was the only thing holding it together. It had started crashing — likely due to hacking attempts.
I'd long since moved on, without the time for a lengthy and complex upgrade. I logged in with the intention of setting up a new backup system for the worst-case scenario and revisiting it later. With a backup in hand and nothing to lose, I handed my coding agent Kiro the root SSH keys and a fresh AWS account — something nobody in their right mind would generally do.
This is what happened next.
The Challenge
A CentOS 6 server — end-of-life since 2020 — running a production web application, mailing lists, email for 12 domains, DNS for 27 zones, and serving dozens of user accounts. The server was potentially compromised, running software from 2013, and could fail at any moment.
The migration target: Amazon Linux 2023 on EC2, with a hybrid architecture leveraging Route 53, RDS, SES, and S3. The constraint: maintain all active services with zero data loss, keep data syncing during the weeks-long migration window, and never give the potentially compromised source server credentials that could reach the destination.
This is the kind of project that typically takes weeks to accomplish. I did it in about 8 hours using Kiro — the AI-powered CLI for infrastructure work and IDE for code updates. Total Kiro cost: 1,000 credits ($20).
What We Were Dealing With
The source server was a time capsule:
- OS: CentOS 6.10, kernel 2.6.32 (2009-era)
- Web: Apache 2.2 with 27 virtual hosts
- PHP: 5.6 (EOL since 2018)
- Database: MySQL 5.6 with 16 databases
- Mail: Sendmail + Dovecot + OpenDKIM + Mailman 2 (Python 2.6)
- DNS: BIND 9.8 serving 27 zone files with vanity nameservers
- SSH: OpenSSH 5.3 (couldn't even connect to modern servers without crypto policy changes)
- Users: 21 active mail accounts across 10 domains (37 originally, 16 obsolete removed)
- Mailing lists: 9 lists with ~4,400 active subscribers, operating since 2003
- Data: 376GB on disk
The web application itself was a PHP 5.6 membership site with forums, a fix database (661 tables), an online store, mailing list archive integration, stock analysis tools, and mobile APIs. It had hardcoded database credentials scattered across hundreds of files, used the deprecated
mysql_* extension, and relied on Apache's .htaccess for authentication and URL rewriting.The Migration Architecture
We designed a hybrid approach:
1
2
3
4
5
6
7
8
9
Source (CentOS 6) → Destination (AL2023 + AWS)
───────────────── ──────────────────────────
Apache 2.2 + PHP 5.6 → Nginx + PHP-FPM 8.3
MySQL 5.6 (local) → Amazon RDS (MariaDB 11.4)
BIND DNS (27 zones) → Route 53 (vanity NS preserved)
Sendmail + Dovecot (mbox) → Postfix + Dovecot (Maildir) + SES
Mailman 2 (Python 2.6) → Mailman 3 (Python 3.12)
Manual backups → S3 (versioned, 60-day lifecycle)
No monitoring → fail2ban + per-site logging
Hour by Hour
Hour 1: Discovery and Access
The first challenge was just connecting. The source server's OpenSSH 5.3 couldn't negotiate with the destination's OpenSSH 8.7. We had to modify the destination's crypto policy (
DEFAULT:SHA1) and generate an RSA host key — the modern server only had ed25519 and ecdsa keys, which the old client didn't support.With access established, we inventoried everything: services, ports, virtual hosts, databases, mail domains, DNS zones, cron jobs, user accounts. Kiro ran the discovery commands, parsed the output, and built structured documentation in real-time.
Hour 2: DNS Migration
Route 53 hosted zones already existed but needed verification. We:
- Compared every DNS record between BIND and Route 53 (57 records across 12 domains)
- Fixed SPF mismatches and added missing DMARC/DKIM records
- Discovered 8 domains had moved to other providers (Cloudflare, GoDaddy, Google)
- Identified 6 domains with no NS delegation (parked/dead)
- Set up vanity nameservers (ns1/ns2 → Route 53 IPs via reusable delegation set)
The registrar glue records were updated, and Route 53 became authoritative — with zero downtime since all records still pointed to the old server.
Hour 3: Infrastructure Provisioning
On the destination:
- Formatted and mounted a 500GB EBS volume at
/home - Installed Nginx, PHP-FPM 8.3, Postfix, Dovecot, OpenDKIM, fail2ban — all from AL2023 repos
- Configured per-user PHP-FPM pools (8 pools on TCP ports 9001-9008, each running as the site owner with
open_basedirisolation) - Set up security groups (SSH, HTTP, HTTPS, SMTP, IMAPS, submission)
- Created an IAM role with S3 and Route 53 permissions
Hour 4: Data Sync Architecture
Security was paramount — the source might be compromised. We designed a pull-only sync:
- Generated an RSA key on the destination
- Authorized it on the source with passwordless sudo for rsync only
- Created 8 granular sync scripts (web, mysql, mail, mailman, dns, httpd, system, users)
- Each script uses file locking to prevent overlapping runs
- Cron runs every 4 hours; S3 backup runs daily
The sync mirrors the OS hierarchy:
/home/src-mirror/etc/httpd/ maps to the source's /etc/httpd/. This made it trivially obvious what came from where.Hour 5: Mail System
The mail migration was the most complex piece:
- Format change: mbox → Maildir (Dovecot's
doveadm importhandles the conversion) - MTA change: Sendmail → Postfix (with SES as smart relay for outbound)
- Auth: SASL via Dovecot (password hashes migrated from
/etc/shadow) - DKIM: Same private key, same selector — DNS record already in Route 53
- Mailman 2 → 3: Complete rewrite of the list management system
We discovered the source's "8.4% bounce rate" was actually 1.0% hard bounces — the rest was Comcast throttling (57K events) and full recipient mailboxes (63K events). This data was critical for the SES production access request, which we drafted and submitted via the CLI.
Hour 6: SSL and Web Application
Let's Encrypt certs for 9 domains, issued via HTTP challenge with a creative approach: certbot on the destination generates challenges, an SSH hook places them on the source server (where DNS still points), Let's Encrypt validates, certs land on the destination.
The web application required:
- A PHP compatibility layer (
compat.php) shimming deprecatedmysql_*functions tomysqli_* - Database connection refactoring from hardcoded credentials to
getenv()with fallbacks .htaccessrules translated to Nginx (basic auth, security headers, URL rewrites, file type handling)- Per-site environment files (
/etc/php-env/<user>.php) injected viaauto_prepend_file
Hour 7: Mailman 3 and Testing
Mailman 3 installation required Python 3.12 (installed parallel to system 3.9 via AL2023 repos). We:
- Created all 9 mailing lists via the REST API
- Extracted subscribers from Mailman 2's pickle files (required stubbing out missing Python modules)
- Imported 4,366 validated email addresses
- Migrated list settings (owners, subject prefixes, reply-to policy, moderation settings)
- Set up Postorius web UI (Django/Gunicorn behind Nginx proxy)
- Copied 18GB of pipermail HTML archives for web app compatibility
Hour 8: Validation and Documentation
Every step had a corresponding validation script. The final suite tests:
- DNS record parity (57 checks)
- Route 53 zone configuration (12 zones)
- SSH connectivity
- Source server health
- Destination infrastructure state
- All 8 websites (HTTP + HTTPS + DB-connected pages)
- Mail services (Postfix, Dovecot, OpenDKIM ports)
- RDS connectivity
- Data sync freshness
- SSL cert validity
The Code Migration: PHP 5.6 → 8.2
Infrastructure migration was only half the battle. The web application — 1,674 PHP files across 8 sites — was written for PHP 5.6 and riddled with security vulnerabilities accumulated over two decades. This is where Kiro IDE took over from Kiro CLI.
Docker-Based Development Environments
We created parallel Docker environments to test the code against both the old and new stacks simultaneously:
- Source environment (port 8080): Apache + PHP 5.6 + MySQL 5.6 — mirrors the existing server exactly
- Destination environment (port 8081): Nginx + PHP 8.2-FPM + MySQL 8.0 — mirrors the target
Both environments bind-mount the same source code, so changes are instantly testable against both PHP versions. The destination environment runs natively on Apple Silicon (Alpine-based), while the source runs under Rosetta emulation (Debian Stretch).
PHP 8.2 Compatibility Layer
The biggest blocker: 510 PHP files used the
mysql_* extension (removed in PHP 7.0). Rather than rewriting all 510 files at once, we created a compatibility shim (secure/compat.php) that polyfills every removed function — mysql_connect, mysql_query, mysql_fetch_array, etc. — by delegating to mysqli underneath.The shim is loaded automatically via the shared
login.php include, so all 510 files work on PHP 8.2 without per-file modification. This buys time for incremental migration while keeping the site functional immediately.Additional PHP 8 fixes applied across all sites:
each()→foreach(removed in PHP 8.0)ereg()→preg_match()/filter_var()(removed in PHP 7.0)mysqli_report(MYSQLI_REPORT_OFF)to suppress exceptions (PHP 8.1 changed the default)- Hardened
totalFixes()and other query functions against missing columns
Security Audit and Remediation
Kiro performed a full static analysis of the codebase and identified 70+ vulnerabilities across 9 categories. The critical fixes applied:
Command Injection (36 files): Every
exec() call to Mailman's CLI used unsanitized $email variables. We wrapped all of them with escapeshellarg().SQL Injection (public endpoints):
search.php — the most exposed page — had direct string interpolation in queries. Converted to prepared statements with mysqli_prepare/bind_param.Credential Removal (15 files across 4 repos): Database passwords were hardcoded in PHP files and committed to git history. We:
- Replaced all passwords with
getenv('DB_PASS') ?: '' - Ran
git-filter-repo --replace-texton all 8 repos to purge passwords from every historical commit - Force-pushed clean histories to GitHub
File Upload Hardening: Replaced
copy() with move_uploaded_file(), added MIME type validation via finfo, and enforced 5MB size limits.XSS Prevention: Created a centralized
e() helper (wraps htmlspecialchars) and applied it to search output and error messages.CSRF Framework: Built token generation/validation helpers (
csrf_token(), csrf_validate()) ready for incremental adoption across forms.Session Hardening: Added
session_regenerate_id() on login, expiration timestamps, and secure cookie flags.Test Suite
We set up PHPUnit 10.5 with 84 tests covering:
- Security helper functions (CSRF, encoding, password hashing, session management)
- mysql_* compatibility layer (function existence, null safety)
- SQL injection prevention (prepared statements, table name whitelisting)
- Command injection prevention (escapeshellarg patterns, source file scanning)
- XSS vector neutralization (9 attack vectors tested)
- Authentication flow (password migration, session expiry)
All tests pass on PHP 8.5 (local) and PHP 8.2 (Docker destination).
Repository Extraction
Each website was extracted from the monorepo into its own GitHub repository using
git subtree split, preserving full commit history. The monorepo references them as submodules:| Purpose | Key Fix |
|---|---|
| Membership community with forums and technical database | Compat shim, security framework, 36 exec() fixes |
| Consumer-facing shop locator with user registration | reCAPTCHA secret → getenv() |
| Technical reference library (657 pages) | Clean (no deprecated functions) |
| Technical reference library (654 pages) | Clean |
| Professional organization membership site | Complete mysql→mysqli rewrite (12 files) |
| Small business site with appointment scheduling | Email header injection prevention |
| Small business site with quote request forms | ereg() → filter_var() |
| Small business site with contact form | Email validation + secret rotation |
By the Numbers
| Metric | Value |
|---|---|
| Total services migrated | 12 |
| Virtual hosts configured | 8 active (25 total in nginx) |
| DNS zones in Route 53 | 12 |
| SSL certificates issued | 9 |
| Databases migrated to RDS | 8 |
| Mailing list subscribers imported | 4,366 |
| Mail users with Maildir | 21 |
| Sync scripts created | 8 + S3 backup |
| Validation scripts | 11 |
| PHP-FPM pools (isolated) | 8 |
| Git repos created/managed | 9 |
| Documentation files | 12 |
| Lines of configuration | ~2,000 |
| AWS services leveraged | 7 (EC2, RDS, Route 53, S3, SES, IAM, DLM) |
| PHP files modernized | 1,674 |
| Security vulnerabilities fixed | 70+ |
| Automated tests written | 84 (126 assertions) |
| Credentials purged from history | 3 passwords across 8 repos |
| Kiro credits used | 1,000 ($20) |
Backup and Lifecycle Strategy
We implemented a defense-in-depth backup strategy:
EC2 Instance (EBS Snapshots via Data Lifecycle Manager):
- Daily snapshots retained for 7 days
- Weekly snapshots retained for 4 weeks
- Monthly snapshots retained for 6 months
RDS (MariaDB):
- Automated backups with 30-day retention
- Point-in-time recovery to any second within the retention window
S3 (Application Data):
- Daily sync of all server data to versioned S3 bucket
- 60-day noncurrent version expiry (acts as a rolling archive)
- Covers: web files, mail config, mailman archives, DNS zones, user data
Git (Code):
- All application code in GitHub repos (9 repos)
- Full history preserved
- Deploy via
git pull— instant rollback viagit reset
Total recovery options: EBS snapshot restore, RDS point-in-time recovery, S3 versioned restore, or git revert. Any single failure mode is covered by at least two independent backup mechanisms.
What Made This Possible
The key wasn't just speed — it was the ability to hold the entire system in context simultaneously. When we discovered that Mailman's pipermail archives were read directly by the PHP app's cron jobs, we immediately knew that affected the Mailman 3 migration strategy, the sync scripts, the archive storage location, and the go-live plan. A human would need to context-switch between documentation, SSH sessions, and config files. The AI maintained all of it in working memory.
Specific capabilities that mattered:
- Multi-server orchestration — SSH to source, SSH to destination, AWS CLI, GitHub CLI, DNS queries, all interleaved naturally
- Pattern recognition — Spotting that the "bounce rate" was inflated by ISP throttling, not actual bounces
- Dependency tracking — Understanding that changing the mail format from mbox to Maildir affected Dovecot config, Postfix delivery, the sync scripts, AND the user home directory structure
- Error recovery — When the crypto policy fix didn't work, diagnosing that the system-wide policy was overriding sshd_config (three attempts before finding the root cause)
- Documentation as a side effect — Every action was documented in real-time, not after the fact
What's Left
The migration is functionally complete but not yet cut over:
- SES production access — Awaiting AWS approval (counter-request submitted with bounce analysis)
- Port 25 inbound — Requires AWS web form submission
- Final data sync — One last mysqldump + mailbox sync at cutover time
- DNS cutover — Single Route 53 change: update A records from old IP to new EIP
- Instance resize — Scale up from 2GB RAM for production load
The actual cutover is a 10-minute operation: lower TTLs, final sync, flip DNS, monitor.
Lessons Learned
- Don't trust surface-level metrics. The "8.4% bounce rate" that got our SES request denied was actually 1.0% hard bounces + ISP throttling + full mailboxes. Digging into the data changed the narrative.
- Security-first sync design pays off. Pull-only sync with no credentials on the potentially compromised source meant we never had to worry about the old server attacking the new one.
- Vanity nameservers are worth preserving. By keeping vanity NS records as the authoritative nameservers (pointing to Route 53 IPs), all 12 domains cut over simultaneously with a single glue record change.
- Per-user isolation is cheap insurance. Each site running in its own PHP-FPM pool with
open_basedirmeans a vulnerability in one site can't read another site's files or credentials. - Git as deployment mechanism. Every site pulls from its own GitHub repo. Deploy is
git pull. Rollback isgit reset --hard HEAD~1. No rsync, no FTP, no manual file copying.
This migration was performed using Kiro CLI for infrastructure orchestration and Kiro IDE for code analysis, security remediation, and PHP modernization. The entire project — from first SSH connection to all sites serving on the new infrastructure with modernized, security-hardened code — took approximately 8 hours of continuous work and cost 1,000 Kiro credits ($20). The equivalent scope would typically require an engineering team and thousands in professional services.
Blog post co-authored by Kiro.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article