From Git Push to Production with Forgejo Actions and AWS
How I built a self-hosted CI/CD pipeline for an Astro site, secured its AWS access, and traced a failed website back to split DNS.
My first deployment process for hihorton.com was a script on my desktop.
The script built the Astro project, synchronized the generated files to S3, and invalidated the CloudFront cache. It worked, but deploying still depended on me remembering to run it after every change.
I was already pushing the project to a Git repository. I wanted that push to become the deployment trigger.
The first automated version used GitHub Actions. Later, I moved the repository and deployment workflow to my own Forgejo instance. That migration forced me to understand what each part of the pipeline was doing instead of treating CI/CD as a box that ran commands after a push.
The result is the deployment path I use now:
Desktop → Git → Forgejo → Forgejo Actions → Runner
→ Astro build → S3 sync → CloudFront invalidation
A push to main starts the process. My desktop is no longer involved after that.
Moving the repository to Forgejo
Moving away from GitHub was not about GitHub being unable to host the project. I wanted to run the central repository and automation platform myself, and I wanted more experience with the pieces behind a hosted Git service.
I migrated the existing hihorton-site repository to Forgejo with its Git history intact. The move did not replace Git. Forgejo became the remote service that stores another copy of the Git repository and accepts pushes from my local copy.
That distinction took me some time to understand.
My desktop and Forgejo do not share one live project directory. They each have a copy of the repository. The local repository contains my working files, staging area, commits, branches, and a reference to the remote repository.
That remote is named origin in my local configuration. origin is not a special server or another Git feature. It is the local nickname for the Forgejo repository URL.
The normal workflow is:
Edit files
↓
git add
↓
git commit
↓
git push
git add selects changes for the next commit. git commit creates a snapshot in the local repository. git push sends local commits to Forgejo.
A commit can exist locally without existing in Forgejo. Multiple local commits can also be pushed together. Uncommitted working files are not uploaded by git push because they are not part of a commit.
This matters to CI/CD because Forgejo Actions builds the commit Forgejo received. It does not have access to changes that only exist in my desktop’s working directory.
Giving the jobs somewhere to run
Forgejo schedules Actions jobs, but it does not perform the build itself. A runner receives the job and executes its steps.
I did not want production deployment jobs running on the Forgejo application server. I created a dedicated Debian LXC in Proxmox for the runner and installed Docker inside it so jobs could run in isolated containers.
I then registered the runner specifically with the website repository.
The separation gives each component a clear responsibility:
- Forgejo stores the Git repository
- Forgejo Actions reads the workflow and schedules jobs
- The runner executes those jobs
- Docker provides an isolated environment for each job
- Astro generates the website
- AWS receives and serves the generated files
Before giving the runner permission to deploy anything, I tested it with simple commands. Then I added repository checkout, dependency installation, the Astro build, and verification that dist/ existed.
Only after those steps worked did I add AWS deployment.
That order mattered. If the first workflow had tried to clone the repository, install packages, build Astro, authenticate to AWS, upload files, and invalidate CloudFront all at once, every failure would have had several possible causes.
Building the workflow
The deployment job runs whenever I push to main.
At a high level, it performs these steps:
- Check out the commit from Forgejo
- Install the project’s locked Node dependencies with
npm ci - Build the Astro site
- Confirm that the generated
dist/directory exists - Synchronize
dist/to the private S3 bucket - Create a CloudFront invalidation
The checkout step is easy to overlook. The runner does not reach back into my desktop and build whatever files happen to be there. It creates its own clean working copy from the commit stored in Forgejo.
npm ci installs the dependency versions recorded in the lock file. Astro then builds the static production site into dist/. The S3 sync makes the bucket match that generated output, and the CloudFront invalidation tells the CDN not to keep serving stale cached versions of changed pages.
The complete deployment sequence is:
git push origin main
↓
Forgejo receives the commit
↓
Forgejo Actions creates a deployment job
↓
The runner checks out a fresh copy
↓
npm ci
↓
Astro builds dist/
↓
dist/ synchronizes to S3
↓
CloudFront cache is invalidated
↓
The production site receives the update
Once the push reaches Forgejo, shutting down my desktop would not stop the deployment. The runner has its own copy of the commit and performs the rest of the work independently.
Separating secrets from configuration
The runner needs AWS credentials before it can update S3 or create a CloudFront invalidation. Those credentials do not belong in the repository or workflow file.
I stored the AWS access credentials as Forgejo Actions secrets. Forgejo makes them available to the job at runtime without committing their values to Git.
I stored non-sensitive deployment settings as workflow variables instead:
- AWS region
- S3 bucket name
- CloudFront distribution ID
A bucket name or distribution ID identifies a resource, but it does not grant access to that resource. The AWS credentials are different because they can authenticate an API request and need to remain secret.
I also created a dedicated AWS IAM identity for deployment instead of giving the workflow broad access to my AWS account. Its permissions are limited to the S3 and CloudFront operations the deployment uses.
This is the practical version of least privilege: the job gets enough access to deploy the site and no more than it needs for that task.
There is still a limitation. The current workflow stores long-lived AWS credentials in Forgejo’s secret storage. They are protected from the repository, but they still exist. A future improvement would be to replace them with short-lived credentials if I can establish an appropriate identity trust between Forgejo and AWS.
Verifying more than the green checkmark
A successful Actions job proves that the runner completed the commands in the workflow. It does not prove that a visitor can load the website.
After building the deployment, I verified the output at several layers.
First, I checked that Astro generated the site and that the expected files existed in dist/. Then I inspected the private S3 bucket and confirmed that those generated files existed there after deployment.
I also inspected the CloudFront distribution:
- The S3 origin was correct
- The custom aliases were present
- The default root object was configured
- The distribution was using the expected origin access settings
That gave me evidence that the source, build, storage, and CDN layers were healthy.
Then I hit a case where the entire CI/CD pipeline succeeded, but hihorton.com still would not load.
My first instinct was to blame the deployment because that was the part I had just changed. The pipeline was new, so it felt like the obvious cause.
It wasn’t.
When a healthy deployment looked broken
I started checking the architecture one layer at a time instead of treating “the website” as one system.
The tools were simple:
curl -I https://hihorton.com
dig hihorton.com
dig @1.1.1.1 hihorton.com
aws s3 ls "s3://${S3_BUCKET}" --recursive
aws cloudfront get-distribution --id "${CLOUDFRONT_DISTRIBUTION_ID}"
S3 contained the new Astro build. CloudFront pointed to the correct origin and had the expected aliases. The deployment job had done its work.
DNS gave me the useful difference. Public resolution and resolution from inside my network did not agree.
Technitium, my internal DNS server, had a Primary zone for hihorton.com. Because it considered itself authoritative for that zone, it answered internal queries from its own local records instead of resolving the public records managed by Cloudflare.
The internal zone was shadowing the public zone.
That meant the AWS website could be healthy, CloudFront could be configured correctly, and the public Cloudflare records could exist while clients in my network still received the wrong DNS answer. From inside the house, the entire website appeared broken.
I replaced the Primary zone with a Conditional Forwarder zone. That lets me keep selected internal overrides while allowing the rest of the hihorton.com namespace to resolve through the appropriate public DNS path.
The pipeline did not need to be fixed. The S3 bucket did not need to be rebuilt. CloudFront did not need another invalidation.
The failure was in a different layer.
Understanding the layers
I originally pictured the system as one long chain:
Desktop → Git → Forgejo → Actions → Runner → Astro
→ S3 → CloudFront → Cloudflare → Visitor
That is useful as a high-level view, but it hides the fact that deployment and visitor traffic are separate paths.
The deployment path is:
Desktop → Forgejo → Actions → Runner → Astro → S3 → CloudFront invalidation
The public request path is:
Visitor → Cloudflare DNS → CloudFront → private S3 origin
An internal client adds another DNS layer:
Internal client → Technitium → internal override, when one exists
└→ forwarded resolution for other names
Each component has a different responsibility:
- Git records project history
- Forgejo stores the central remote repository
- Forgejo Actions schedules automation
- The runner executes the build and deployment
- Astro generates the static website
- S3 stores the production files
- CloudFront caches and serves the site while protecting access to S3
- Cloudflare manages public DNS
- Technitium manages internal DNS and selected split-DNS overrides
A failure in one layer can make every layer behind it look broken. DNS can make a healthy CDN and origin unreachable. A stale CloudFront object can hide a correct S3 deployment. A successful Astro build says nothing about whether the DNS name resolves correctly.
How I troubleshoot it now
I no longer start with the component I changed most recently. I start with the path and verify each boundary.
For a deployment problem, I check:
- Did Forgejo receive the expected commit?
- Did the runner check out that commit?
- Did
npm ciand the Astro build complete? - Does
dist/contain the expected site? - Did those files reach the correct S3 bucket?
- Did the CloudFront invalidation complete?
For an access problem, I check:
- Does the hostname resolve publicly?
- Does it resolve to the same place internally?
- Can CloudFront serve the expected file?
- Is CloudFront using the correct origin and alias?
- Does the private S3 origin contain the expected object?
The difference between those two checklists is the most useful thing I learned from the incident. “The website is down” is not a diagnosis. It is only the visible symptom.
The pipeline can now take a commit from my desktop and deploy it without my desktop doing anything after the push. More importantly, I understand where that automation ends, where the public request path begins, and how to test the layers without guessing.