Guide · October 4, 2026 · Dr. Safi Mojidi
Implementation plan: moving a nonprofit website to files you own
The technical plan we followed to replace a hosted website builder with a static site on AWS, published by GitHub Actions. De-identified, with the four mistakes that cost us the most time so you can skip them.
This is the plan we actually executed, with our account identifiers removed. It assumes a nonprofit with a domain name it controls, a free GitHub account, and an AWS account with a credit card on file (the bill at small-nonprofit traffic is cents per month). The same shape works on other clouds; the pieces are generic.
The target architecture
Five pieces, each doing one job.
- A Git repository holds the source: a
build/folder with the page sources, aredirects.jsonmap of old URLs to new ones, an infrastructure template, and two automation workflows. The generated site is not committed; it is rebuilt on every change. - A build script (a few hundred lines of Python) turns the sources into finished HTML. It writes the shared header, footer, navigation, and calls-to-action once, inlines the stylesheet, resizes photos, writes the sitemap, and refuses to publish any page that still contains placeholder text.
- Object storage (an S3 bucket) holds the finished files. The bucket is private; only the content delivery network can read it.
- A content delivery network (a CloudFront distribution) serves the files over HTTPS with a free certificate, rewrites clean URLs like
/donate/to the right file, and answers the old URLs with permanent redirects so nothing you ever shared goes dead. - A publishing job (GitHub Actions) runs on every push to the main branch: build, check, upload, refresh the cache, and smoke-test three pages. It signs in to AWS with a short-lived identity token, so there are no long-lived access keys anywhere.
Phase 1: the content (half a day)
- Inventory the old site. List every page URL. You will need this twice: once to decide what to keep, once to build the redirect map. Export any text you cannot easily recreate.
- Decide the page list and the navigation. We went from a sprawl of pages to thirty-seven with a six-item navigation. Each page got a title, a one-sentence description, and a reason to exist.
- Write the pages as sources, not as finished HTML. Some of ours are wireframe exports; most are short Python calls that produce a hero, a card grid, and a call to action from text. The point is that a volunteer edits sentences, never layout.
- Mark every unknown with a visible placeholder in a fixed format, and have the build log them to a file. Ours is a bracketed tag that starts with the word SWAP and says what belongs there. The build replaces each with honest interim copy (“Pricing announced soon”) and writes the original intent to
NEEDS-INPUT.md. The checker refuses to publish any page where a raw placeholder survived. - Build the redirect map. For every old URL, pick the new page it should land on. Ours has fifty-seven entries. This is the single most valuable file for anyone who has ever put a link in a grant report.
Phase 2: the infrastructure (two hours, once)
- Write the infrastructure as a template, not as clicks in a console. Ours is one CloudFormation file that creates the bucket, the distribution, the certificate, the URL-rewrite function, and the deploy role. Keep it in the repository. A small script regenerates the template from
redirects.json, so adding a redirect never means editing infrastructure by hand. - Create the stack in
us-east-1(certificates used by the CDN must live there) with the staging hostname, for examplenext.yourdomain.org. While it creates, the certificate service shows a validation record to add at your DNS provider. Add it; the stack waits until it resolves. - Point the staging hostname at the distribution with a CNAME record.
- Connect GitHub to AWS by storing four values as repository variables: the deploy role’s identifier, the bucket name, the distribution identifier, and the site URL. The workflow reads them. Nothing secret is stored.
- Push. The first run publishes the site to the staging hostname.
Phase 3: cutover (one hour, when ready)
- Update the stack so the primary hostname is your apex domain and the additional hostname is
www. A new certificate is issued; add its two validation records. - Change the site URL constant in the build (it drives canonical links and social previews) and the
SITE_URLvariable, and push. - At the DNS provider, point the apex at the distribution (an ALIAS or ANAME record, or your provider’s equivalent) and
wwwas a CNAME. The distribution redirectswwwto the apex. - Leave every mail record alone: MX, SPF, DKIM, DMARC. Your mailboxes do not know or care where the website lives.
- Do not renew the old platform. Keep the domain registration; it is separate from the website plan and usually cheap.
The four mistakes that cost us the most time
The identity provider already existed. If your AWS account has ever connected to GitHub before, the OpenID Connect provider is already there and a template that tries to create it again fails and rolls back. Make it a parameter and default it to “no”.
Certificate validation options without a hosted zone. If your DNS is not in Route 53, the certificate resource must omit its validation-options block entirely. Including it with an empty zone fails with an unhelpful message about a null domain.
GitHub changed the shape of its identity token. The subject claim used to read repo:Owner/Repo:ref:refs/heads/main. It now stamps numeric identifiers into it: repo:Owner@12345/Repo@67890:ref:refs/heads/main. A trust policy that matches only the old pattern is refused with “not authorized to perform sts:AssumeRoleWithWebIdentity”. Match both patterns with a wildcard condition. Check the sign-in audit log for the exact subject your run presented; it tells you in one line.
Waiting on a cache refresh you are not allowed to read. Our deploy role could create a cache invalidation but not read its status, so the “wait until done” step failed after the site was already live. Either grant the read permission or do not wait; the refresh completes in about a minute regardless.
Operating it afterwards
- A text change is an edit on the code host’s website and a three-minute wait. No tools.
- A new photo is a file dropped into a folder plus one line naming it.
- A new redirect is one line in
redirects.json. - Undo is one command, because every publish is a commit.
- Review the old-URL traffic for a month after cutover and add any redirect you missed.
What this is not
It is not a content management system with roles, drafts, and a visual editor. If your organization has many non-technical editors publishing daily, you may still want one. For a small nonprofit with a few pages that change a few times a month, this is less work, far cheaper, and entirely yours.
The full source is in our repository, and the student project brief in this section turns the plan into a semester-long service-learning project.