AWS Builder Center
Migrating an EOL Linux Server to AWS in 8 Hours with Kiro

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_basedir isolation)
  • 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 import handles 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 deprecated mysql_* functions to mysqli_*
  • Database connection refactoring from hardcoded credentials to getenv() with fallbacks
  • .htaccess rules translated to Nginx (basic auth, security headers, URL rewrites, file type handling)
  • Per-site environment files (/etc/php-env/<user>.php) injected via auto_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:
  1. Replaced all passwords with getenv('DB_PASS') ?: ''
  2. Ran git-filter-repo --replace-text on all 8 repos to purge passwords from every historical commit
  3. 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:
PurposeKey Fix
Membership community with forums and technical databaseCompat shim, security framework, 36 exec() fixes
Consumer-facing shop locator with user registrationreCAPTCHA secret → getenv()
Technical reference library (657 pages)Clean (no deprecated functions)
Technical reference library (654 pages)Clean
Professional organization membership siteComplete mysql→mysqli rewrite (12 files)
Small business site with appointment schedulingEmail header injection prevention
Small business site with quote request formsereg() → filter_var()
Small business site with contact formEmail validation + secret rotation

By the Numbers

MetricValue
Total services migrated12
Virtual hosts configured8 active (25 total in nginx)
DNS zones in Route 5312
SSL certificates issued9
Databases migrated to RDS8
Mailing list subscribers imported4,366
Mail users with Maildir21
Sync scripts created8 + S3 backup
Validation scripts11
PHP-FPM pools (isolated)8
Git repos created/managed9
Documentation files12
Lines of configuration~2,000
AWS services leveraged7 (EC2, RDS, Route 53, S3, SES, IAM, DLM)
PHP files modernized1,674
Security vulnerabilities fixed70+
Automated tests written84 (126 assertions)
Credentials purged from history3 passwords across 8 repos
Kiro credits used1,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 via git 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:
  1. Multi-server orchestration — SSH to source, SSH to destination, AWS CLI, GitHub CLI, DNS queries, all interleaved naturally
  2. Pattern recognition — Spotting that the "bounce rate" was inflated by ISP throttling, not actual bounces
  3. 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
  4. 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)
  5. 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

  1. 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.
  2. 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.
  3. 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.
  4. Per-user isolation is cheap insurance. Each site running in its own PHP-FPM pool with open_basedir means a vulnerability in one site can't read another site's files or credentials.
  5. Git as deployment mechanism. Every site pulls from its own GitHub repo. Deploy is git pull. Rollback is git 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.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article