From zero to live, on a Sunday

Read in Portuguese

I wanted a simple place to publish technical writing in Portuguese and English. Not a profile, and not a portfolio. A site where a post is a Markdown file, the build is static, and publishing is a commit.

This one went from nothing to live on a Sunday. The domain already existed. The rest did not.

What I wanted

Both languages from day one. Portuguese as the default, English on the same piece when it is worth it. No CMS, no database, no JavaScript framework bundle in the browser. If I open the repo a year from now, I should be able to see where a new post goes without hunting through config.

The home page did not need to introduce me. The GitHub profile already does that. This site is for posts.

The stack, as it actually is in the repo

The site is Astro 7.4.4, with output: "static". TypeScript 7.0.2. @astrojs/mdx 8.1.2 is installed for the day a post needs MDX. This one is plain Markdown. RSS is @astrojs/rss 4.0.19, one feed per language. The sitemap is @astrojs/sitemap 3.7.4. Code highlighting is Shiki, theme vitesse-dark. The GitHub mark comes from simple-icons 16.36.0. Fonts are local, through Fontsource: Newsreader, Source Sans 3, and IBM Plex Mono. package.json asks for Node 22.23.4 or newer.

Internationalization is Astro’s own. Portuguese is the defaultLocale, both languages use a prefix, and the root redirects to /pt/. The i18n part of astro.config.ts:

export default defineConfig({
  site: "https://douglasrubim.com",
  output: "static",
  trailingSlash: "always",
  i18n: {
    defaultLocale: "pt",
    locales: ["pt", "en"],
    routing: {
      prefixDefaultLocale: true,
      redirectToDefaultLocale: false,
    },
  },
});

redirectToDefaultLocale is off on purpose. The redirect from / is a 301 in src/pages/index.astro and the same rule in vercel.json. A static site does not run middleware. If the redirect lived only there, Vercel would just serve a root page that sits still.

The blog is a content collection. Each post declares title, description, date, tags, lang, and, when a translation exists, the same translationKey in both files. lang has to match the folder (pt or en). If it does not, the build fails. draft: true shows up in npm run dev and stays out of the production build, the index, tag pages, RSS, and the sitemap.

npm run build emits no .js file. The mobile menu is a <details> element, with no script. The only script on the page is the theme one, inline and short, so light and dark do not flash on load. The scanline, and the title glitch that only runs on hover, stop when the system asks for prefers-reduced-motion.

An agent, and me sending back what was wrong

I did not sit down and type the site line by line. I asked a Cursor cloud coding agent to build it, driven by my assistant. I looked at what came out and sent back what did not work.

The first version compiled. Visually it was a column of text. I thought it was bad: too plain, too much like a template. I said so. A redesign came back, with a large hero, cards, and icons.

The icons were invented. You could tell immediately. A mark drawn in a hurry does not look like a mark, even in the “right” color. The fix was simple-icons for known brands. Where I already had an official icon file, the agent downloaded that file into the repo, instead of improvising a drawing and instead of hotlinking.

Then I asked for cyberpunk that was still readable. Near-black navy, cyan and magenta, a fine grid, a low scanline, bracket corners on the cards. The glitch is hover-only and respects reduced motion. Light mode is not a lazy invert. It is the same idea in daylight, with the neon darkened, or the text does not hold contrast.

The top bar on a phone was huge. It wrapped onto several lines, with too much pill. I asked for a single row, about 56px: the name on one side, language and theme on the other, links in a menu. The menu is <details> and <summary>. No library, no JavaScript state. On desktop the bar stayed as it was.

Last, the home page. It had turned into an about page, repeating what is already on GitHub. I removed the enormous name and the introduction sections. What is left is one sentence and the list of posts. Past eight, there is a link to the index.

What actually helped in that loop:

  • Specific feedback. “It looks bad” does not steer. “The top bar is huge, on mobile it has to be one row” does.
  • A screenshot at each step. Prose about layout lies easily. The picture does not.
  • An official logo or none. An invented icon looks amateur the same day.

The agent shortens the path between a decision and a build. I am the one who looks at the screen and says what stays.

Vercel, then Cloudflare

Deploy is Vercel, on the Astro preset. The command is npm run build, the output directory is dist, and there are no environment variables. A push to main publishes. That is how the first version went up, on a Vercel domain, before this domain pointed here.

douglasrubim.com was already on Cloudflare. A Redirect Rule sent visitors to my GitHub. I did not delete that rule. I turned it off. If I need to remember what it did, it is still there, inactive.

In DNS, both records are DNS only, the grey cloud. With the orange proxy, Cloudflare terminates TLS in front, and Vercel does not issue the domain certificate the direct way. On the grey cloud, Vercel sees the domain and handles HTTPS.

Type    Name    Content                  Proxy
A       @       76.76.21.21              DNS only
CNAME   www     cname.vercel-dns.com     DNS only

www redirects to the apex. With or without www, the address bar ends at https://douglasrubim.com.

How the next post goes in

A Markdown file in the language folder. This post is the pair src/content/blog/pt/do-zero-ao-ar-num-domingo.md and src/content/blog/en/from-zero-to-live-on-a-sunday.md. Both carry translationKey: sunday-to-live. Each slug can differ. The page shows “Read in Portuguese”, and hreflang points at the real URL, not at a copied path.

The frontmatter the schema accepts:

---
title: "Title"
description: "One or two sentences. They go on the index, in meta, and in RSS."
date: 2026-11-01
tags:
  - astro
lang: en
translationKey: sunday-to-live
draft: false
---

The date is YYYY-MM-DD. Each tag gets a page at /en/blog/tags/<slug>/ (and the /pt/ equivalent). The tag slug is the text in lowercase, with accents stripped.

npm run dev shows the file immediately. Production is a commit on main. Each language feed is /pt/rss.xml and /en/rss.xml. The sitemap is /sitemap-index.xml.

That was the Sunday I wanted: a small repo, an agent to shorten the path, and a domain that opens the site instead of sending people somewhere else.

All posts