Skip to content

    One AWS account held everything we run: two production apps with paying users, a marketing site, the login system, all the data, and years of abandoned experiments. Nothing was broken. But there was no line anywhere, one mistake could take out both products, and the only way to know what was running was to remember.

    Over four days in July 2026 we rebuilt it: one account per app, plain servers running Docker instead of two managed platforms, one deploy command instead of three systems, and no load balancers at all. Cost fell by roughly $55 to $65 a month.

    There was one real outage, and one near-miss that could have silently detached paying customers from their subscriptions. Both are covered in full below, along with an architectural decision we made under time pressure on a premise that one command would have disproved.

    Where it started

    Everything lived in one account, and each piece had arrived by a different route at a different time. None of it was wrong on its own. Together it meant there was no boundary anywhere.

    • Both app backends ran on Elastic Beanstalk, each dragging along a load balancer neither app needed.
    • One product's marketing site ran on AWS Amplify, a second and completely different deployment system.
    • Deploys ran through GitHub Actions: slow, plus a permanent admin door from GitHub into AWS.
    • Both apps shared one disk, so either could corrupt the other's data.

    The real cost was not money. It was that the blast radius of any mistake was the whole company, and nobody could answer "what is running right now?" from anything but memory.

    The four decisions

    Four choices set the shape of everything that followed. Three of them held. One did not.

    One account per app

    • Each app gets a sealed box; the shared account keeps only what both genuinely need.
    • Accounts are free. The only cost is remembering to switch between them.

    Plain servers with Docker, not a platform

    • Elastic Beanstalk and Amplify hide a lot, which is pleasant until something breaks and the hidden parts are exactly what you need to see.
    • One small server running Docker, with Caddy handling HTTPS, fits in one person's head.
    • Caddy also issues free certificates automatically, which removed the load balancer and the certificate service in a single move.

    The login system stays put: a decision made on a false premise

    • The reasoning at the time: passwords cannot be exported from AWS Cognito. Not "it's hard": the API does not exist, because passwords are stored one-way by design. Moving the pool would force every user to reset their password, so it stayed in the shared account behind a narrow cross-account door.
    • That reasoning was wrong, and nobody checked it. Every single account in that pool signs in with Google or Apple. Not one of them has a password. There was nothing to reset.
    • The decision was made under time pressure from a general fact about Cognito, without spending the sixty seconds it would have taken to list the users and see that the general fact did not apply.
    • It cost real money: the cross-account door it justified went on to cause the only user-facing outage of the whole project. The pool was moved properly two days later.

    Deploy by leaving a file in S3

    • No SSH port and no key file: the laptop uploads a zip to S3 and tells the server to fetch it.
    • Access is controlled by permissions instead of a secret, and every session is logged.

    How the switch happened

    The whole plan rested on one idea: build the new thing completely, verify it, then move the domain name. DNS is the only switch that has to flip, and it flips back.

    • Build and verify the new server while the old one keeps serving every real user.
    • Copy the data across twice: once early, once at the last moment to catch anything written in between.
    • Point the domain at the new address, with the refresh interval set to 60 seconds beforehand so the world follows within a minute.
    • Leave the old stack running for days. Rolling back is one DNS change.
    • Only then delete anything, and only after both apps were confirmed working normally.

    The test that mattered most came before the deletions: both databases were compared row by row: 2,617 events, 475 users, all seven billing tables identical. Deleting production data on the assumption that a copy worked is how people lose companies.

    Five things that went wrong

    The honest part, and the most useful one.

    1. The certificate quietly went fake

    • Caddy tries to obtain an HTTPS certificate the moment it starts. During testing it started several times while the domain still pointed at the old server, so every attempt failed.
    • After enough failures Let's Encrypt makes you wait, and Caddy did exactly what it is designed to do: switched to a staging certificate service, whose certificates browsers reject.
    • Fix: after DNS moved, clear Caddy's saved certificate state and restart. A real certificate was issued on the first try.
    • Lesson: point DNS at the new server before starting Caddy for the first time.

    2. The old environments refused to delete

    • Deleting the old setup failed twice with "resource has a dependent object", after ten minutes of waiting each time.
    • The cause was a knot: the old shared disk was attached to a firewall group being deleted, and the two apps' firewall groups referenced each other. Nothing could be removed because everything was holding something else.
    • Fix: move the disk to a different firewall group, cut the two cross-references, then delete in the right order. Both completed in about 40 seconds.
    • Lesson: "dependent object" almost always means another firewall rule points at this one. Look for who references it, not what is inside it.

    3. Logins broke, and the error blamed the wrong account

    • The one that reached real users. After the move, signing in failed with a message saying the login pool did not exist in the app's own account. It was never there: it lives in the shared account.
    • AWS builds that message from who is asking, not from where the thing actually lives, so the error points somewhere false and sends you hunting for a pool that never existed.
    • The real cause: the code had four separate places that connect to the login system, and the migration converted only one. The other three still connected the old way and were denied, breaking Apple sign-in, account deletion, and Apple's "user revoked access" notifications, which would have failed silently.
    • Fix: one shared function is now the only way to build a login connection.
    • Lesson: after moving anything across an account boundary, find every call site, then check the permission list against every operation the code performs, not just the one you happened to test.

    4. The tests were lying

    • Found while fixing the above. A test already existed asserting that a failed account deletion raises a clear error, and it passed while the real thing was broken.
    • The new shared function caches its connection. The first test's fake connection was cached and handed to every later test, so the second test never exercised what it claimed to.
    • Lesson: a passing test that shares hidden state with an earlier test is not a passing test. Caching is usually the culprit.

    5. Two safety settings were silently off

    • Automatic backups were off on both new data disks. They are on by default from the web console and off from the command line, so the move had quietly lost backup coverage the old setup had.
    • The old nightly data export was still running against the dead server, writing to the same place as the new one. Whichever finished last won, so good data could have been overwritten with stale data indefinitely, with no error anywhere.
    • Lesson: check the defaults of whichever tool you used. The console and the command line disagree, and the command line is quieter.

    Round two: moving the login system

    Two days later the decision to leave the login system in the shared account got revisited, and the premise it rested on collapsed on first contact with the data. Of the accounts in that pool, 21 sign in with Apple and 5 with Google. Zero use an email and password. The blocker had never existed, and one command would have shown that at the time.

    What actually made the move hard was not the users. It was the iPhone app. It ships its web code compiled inside the binary, and the login system's address is compiled in with it. Changing that needs an App Store release, and the app and the server have to switch together: an old app against a new server fails, and a new app against an old server fails too. There is no ordering that avoids a window.

    So the work was staged: build the new pool alongside the old and prove it, then bundle the switch into a release that was already going out. The pool was configured to be byte-identical to the original (settings, app client, both providers) and verified by diffing the two rather than by eye.

    The part that nearly went badly

    • Every user's internal account ID is derived by hashing their login ID. A separate table exists precisely to survive that: it maps a login to its original account ID, so a new login system does not orphan anyone.
    • That table had been silently destroyed on every single deploy for months. The setting naming its location was never configured, so it defaulted to a path inside the container, which is rebuilt each time. Its two sibling databases, billing and analytics, were both configured correctly. Only this one was missed.
    • It had never mattered, because the hash is deterministic: with a fixed login system, the same account ID regenerated after every wipe. The migration turned a dormant bug into a live one. New login system, new login IDs, new hashes, so the first people to sign in after the switch were quietly issued brand-new accounts, disconnected from their own billing and history.
    • It was caught by asking a question that could have been skipped: the sign-ins work, but did those people get their original accounts back? The answer was no. Overlap between live accounts and billing records: zero.

    The honest version: nothing was lost, and that was luck rather than design. The handful of people who happened to sign in first had no billing records; the ones who did had not yet opened the app. Had the order been reversed, the first sign-in would have silently detached a paying customer from their subscription. No error, no alert, nothing to notice until someone complained. The margin between a clean migration and a data-integrity incident was who opened their phone first.

    The repair: rebuild the mapping table from the old system's data, deriving each person's original account ID and pairing it with the login name, which is stable across both systems. Write it to the persistent disk, then point the setting at it so it can never be wiped again. Verified afterwards by proving that every billing record which had ever been reachable from a login was reachable again.

    The lesson is the one worth carrying out of this whole project: verifying that a migration works is not the same as verifying it preserved anything. Sign-in succeeded for every test user while the data behind it was being detached.

    Where it landed

    Four days of work, measured against what was there before.

    • Accounts: from 1 holding everything, to 3 in use and 1 reserved.
    • Login system: from the shared account behind a cross-account door, to inside the app's own account with no door at all.
    • Load balancers: from 2 to 0.
    • Deployment systems: from 3 to 1, a single make deploy.
    • HTTPS certificates: from paid and tied to load balancers, to free and renewed automatically.
    • GitHub's access to AWS: from a permanent admin door to none.
    • App data: from one shared disk to separate disks per app, with backups on.
    • Signing in: from one account and switching roles by hand, to one portal covering all four.
    • Cost: roughly $55 to $65 per month lower.

    Once the old stack was gone, so was a long tail nobody had looked at in a year. It came to 219 stored application versions, a 665 MB bucket of old deploy files, and a 224 MB container registry from an abandoned experiment. Then eleven empty log folders, four orphaned firewall groups, three certificates for things that no longer existed, and a DNS zone for a dead side project.

    Then the tidying: a single sign-in portal covering all four accounts with an authenticator app, billing and security notices pointed at a monitored address, and settings templates added to both repositories. One had none at all, so 43 production settings were undocumented. Neither repository has ever contained a real secret.

    Lessons

    What we would tell anyone doing this at a similar size.

    • Move the domain name last. Build in parallel, verify, then flip DNS. One switch, and it flips back.
    • Compare the data before deleting the source. Row counts, not optimism.
    • Keep the old thing running for days. A few extra dollars beats having no way back.
    • Read AWS permission errors carefully. They name the caller's account, not the resource's. That one detail cost the most time here.
    • After crossing an account boundary, find every call site. One converted path proves nothing about the others.
    • Check what your tool defaulted to. The console and the command line disagree, quietly.
    • Check the premise before you design around it. A whole architectural compromise, and the outage it caused, rested on a constraint that one command would have disproved.
    • "It works" is not "it preserved the data." Every test sign-in succeeded while the accounts behind them were being silently detached. Verify continuity, not just success.
    • Find the storage nobody configured. Two of three databases were explicitly placed on permanent disk. The third defaulted onto disposable disk and was destroyed on every deploy for months without a single symptom.
    • Simple beats managed at this size. A platform saves work at scale and hides things you need at small scale.

    The shape now: one organization, one account per app, one small server each, one deploy command, one shared place for data, one portal to sign in. Adding a third app means following the recipe. The account is already waiting.

    Before You Go

    Get the next field note

    One email when we publish, covering what we built, what it cost, and what went wrong.

    We use your address for this list and nothing else. Tell us to stop and we will.

    Next Step

    Ready to Deliver Your Next Data Product?

    Share your goals by email and we will respond with a practical next-step plan for scope, timeline, and delivery.

    Decision Intelligence Systems

    Athena Data Labs

    Designed, built, shipped, and run by us.

    © 2026 Athena Data Labs
    A division of Athena Analytics L.L.C.