Publishing a portfolio sounds simple until the task includes everything at once: local code, a remote repository, a reproducible build, credentials, a custom domain, DNS, HTTPS and two languages. The risk was not running a deployment command. It was building a system in which the next release would be predictable and would not require repeating manual decisions.
This article records the architecture I used for my own portfolio. The goal was to reach one simple rule:
every approved change enters
main; automation validates the project and publishes one version; domains and languages are different ways to reach that same release.
This is not a universal recipe. It is an implementation case with explicit boundaries, including what remains a next step.
The final shape
push to main
→ GitHub Actions
→ npm ci
→ build + release validation
→ Wrangler uploads dist/ to Cloudflare Pages
→ Cloudflare serves the custom domain
The project remains a static Astro site. I did not have to convert the whole application to SSR to
gain continuous deployment or run one small decision at the edge. The build generates dist/,
and Wrangler publishes that directory to the existing Cloudflare Pages project. This route is also
covered by the official guides for deploying Astro to Cloudflare
and Direct Upload with Wrangler.
One production source
The first principle was to avoid two competing processes. GitHub does not host one copy while Cloudflare hosts another. GitHub stores the code and runs automation; Cloudflare Pages receives the production artifact.
The workflow runs only on a push to main or through workflow_dispatch:
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: cloudflare-pages-production
cancel-in-progress: true
The branch filter makes main the publication contract. Concurrency prevents two production
deployments from racing toward the same destination. GitHub documents both
push branch filters
and the use of environments and concurrency for deployments.
Validate before uploading
Automation does not begin with the upload. It reproduces the installation and validates what will be published:
- name: Install dependencies
run: npm ci
- name: Build and validate release
run: npm run release:check
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=portfolio
In this project, release:check combines the Astro build with output validation. That second step
checks the bilingual inventory, metadata, internal links, assets and the absence of editorial
routes from the public artifact. Deployment starts only when the command succeeds.
The token and account identifier do not live in YAML. They are read from GitHub Actions Secrets. Besides keeping credentials out of version control, this separation makes it possible to restrict the token to the permissions the deployment actually requires. GitHub documents how to create and use secrets in workflows, and its security guidance reinforces the principle of least privilege.
A custom domain is a DNS change, not a build change
After the pages.dev address responded correctly, I connected the primary domain to the same Pages
project. That order separated two diagnostic questions:
- is the artifact correct and reachable on Cloudflare?
- is the domain delegated and associated correctly?
For an apex domain such as example.com, Cloudflare requires the zone to exist in the same account
as the Pages project and its nameservers to point to Cloudflare. The correct flow also includes
adding the hostname under Custom domains; manually creating a CNAME is not a substitute for
that association. The sequence is documented in the Cloudflare Pages guide to
custom domains.
There was another operational constraint: migrate the website without deleting records used by other services. Before changing DNS authority, I reviewed and preserved the existing email and verification records. A working website with broken email is not a successful deployment.
One release, multiple domains
Additional domains do not need additional builds. The intended architecture is:
- one canonical domain for indexing and sharing;
- secondary domains as entry points;
- permanent redirects from secondary domains to the canonical one;
- one origin, one operational certificate path and one content pipeline.
At the time of this record, the primary domain is active. The .com.br domain remains a next step
because it is still under another DNS authority. It is not presented here as completed. Publishing
it requires adding the zone, preserving its records, changing nameservers at the registrar and only
then enabling the redirect to the canonical domain.
The hidden loading state on first access
The most visible problem appeared after the domain went live. The / route was a static Astro
redirect to /en-us/. On a fast connection that could look acceptable. During a real Safari check,
however, the browser displayed the intermediate “Redirecting from / to /en-us/” page before the
site loaded.
The requirement changed: detect the language preference without changing the initial URL and without making the browser perform a second navigation.
The solution kept the already generated localized pages and added one Pages Function at the root:
GET /
→ read Accept-Language
→ pt: internally serve /pt-br/artigos/
→ en or fallback: internally serve /en-us/insights/
→ return 200 while keeping the URL at /
In Cloudflare Pages, functions/index.js maps to the root route. The Function can request a static
asset from the same project through env.ASSETS.fetch(). There is no Location header and no new
browser navigation. The response also declares Content-Language and Vary: Accept-Language.
This approach uses capabilities documented in the official Pages guides for
Function routing,
env.ASSETS.fetch()
and Accept-Language localization.
Explicit /pt-br/... and /en-us/... routes still exist. They remain useful for links, sharing,
alternates and manual language selection. What disappears is the mandatory jump on the first visit
to the domain.
Cost and scale
For this type of website, purely static requests on Pages do not consume the Functions quota. The
/ route does because it invokes a Function, so it counts against the Workers quota. Current
Cloudflare documentation states that the free plan shares a daily allowance between Workers and
Pages Functions; cost should therefore be reviewed if traffic grows or more dynamic routes are
added. The current details live in Pages Functions pricing.
On GitHub, cost depends on repository visibility, the selected runner and the account plan. I did not treat “free CI” as a permanent guarantee; the reliable reference is the current GitHub Actions billing documentation.
The deployment completion checklist
The work is complete only when every layer responds:
npm cireproduces installation;- the build uses the Node version declared by the project;
release:checksucceeds;- the
mainworkflow completes successfully; - the
pages.devaddress responds; - the canonical domain responds over HTTPS;
- email and verification DNS records remain present;
/returns200without aLocationheader;Accept-Language: pt-BRserves Portuguese;Accept-Language: en-USserves English;- no credential appears in the repository or logs.
The main lesson
Deployment automation is more than “publish on merge.” It makes the contracts among code, build, credentials, infrastructure and the entry experience explicit.
The design became simpler when every system received one responsibility:
- Git stores history;
maindefines what may enter production;- GitHub Actions reproduces and validates;
- Wrangler transports the artifact;
- Cloudflare Pages serves the site;
- DNS connects names without duplicating applications;
- the root Function resolves only the initial language choice.
This separation prevents every new domain, language or release from becoming a parallel project. The desired result remains one system: change, validate and publish with evidence.














