truong.blog
← Back to Tech

Blog — Full Setup Walkthrough (Deep Dive)

·15 min read
#devops #astro #go #docker #ci-cd

Everything we did, from empty repos to a live HTTPS site at https://hoongan.art. Written so you can understand why each step exists, reproduce it, and debug it later.

Final result:

  • 🌐 Blog (Astro static site): https://hoongan.art
  • 📡 API (Go): https://hoongan.art/api/ + /healthz
  • 🔁 CI/CD: push to main → GitHub Actions → auto-deploy
  • 🔒 HTTPS: automatic Let’s Encrypt cert (auto-renews)
  • 🖥️ Server: 109.123.229.225 (shared box), blog isolated in its own Docker stack

Table of contents

  1. The big picture (architecture)
  2. Phase 1 — Code scaffolding
  3. Phase 2 — The UI redesign
  4. Phase 3 — Connecting to the server (SSH)
  5. Phase 4 — Deploying with Docker
  6. Phase 5 — CI/CD with GitHub Actions
  7. Phase 6 — Domain + DNS + HTTPS
  8. The bugs we hit & how we fixed them
  9. Cheat sheet — day-to-day commands
  10. Glossary

1. The big picture (architecture)

   You ── git push main ──▶  GitHub (truongngoblog/frontend, /backend)

                                   │  GitHub Actions runs

                            ┌──────────────┐
   frontend repo ──────────▶│  build dist/ │── rsync over SSH ─┐
                            └──────────────┘                   │
                            ┌──────────────┐                   ▼
   backend repo  ──────────▶│ build image  │── scp + docker ──▶ Server 109.123.229.225
                            └──────────────┘   load over SSH   │

            Internet ──https://hoongan.art──▶ chamee-caddy (:443, shared)
                                                  │   reverse proxy
                                  ┌───────────────┼─────────────────┐
                                  ▼                                 ▼
                          blog-web (nginx :8080)            blog-api (Go :8090)
                          serves static dist/               SQLite at /opt/blog/data

Two repos, two responsibilities:

  • frontend = Astro. Turns Markdown/MDX posts into a fast static site (plain HTML/CSS). No server needed to render — just files.
  • backend = Go. Handles the dynamic things static files can’t: view counter, contact form, newsletter. Stores data in SQLite.

Why a shared reverse proxy (chamee-caddy)? The server already runs other people’s apps (chamee/elearning/laws), and a container called chamee-caddy already owns ports 80/443. Only one program can own a port. So instead of fighting it, the blog sits behind that existing Caddy, which routes hoongan.art to us and everything else to them.


2. Phase 1 — Code scaffolding

Both GitHub repos started empty. The plan (KE-HOACH.md) chose the stack: Astro frontend + Go backend.

2.1 Cloning

git clone https://github.com/truongngoblog/backend.git
git clone https://github.com/truongngoblog/frontend.git

2.2 Backend (Go API)

Layout we created:

backend/
├── cmd/api/main.go          # entry point: reads env, starts HTTP server
├── internal/server/         # HTTP routes + handlers (+ tests)
├── internal/store/          # SQLite persistence (+ tests)
├── go.mod                   # module + dependency list
├── Makefile                 # shortcuts: make run / test / build
└── README.md

Key choices:

  • Router: chi — a tiny, idiomatic HTTP router for Go.
  • Database: SQLite via modernc.org/sqlite — a pure-Go driver, meaning no C compiler needed (CGO_ENABLED=0). This is why our Docker image can be tiny and static.
  • Endpoints: GET /healthz, GET/POST /api/posts/{slug}/views, POST /api/contact, POST /api/newsletter.

How dependencies got resolved and tests run:

cd backend
go mod tidy        # downloads deps, writes go.sum
go test ./...      # runs all unit tests

go test ./... output ok for both internal/server and internal/store → green.

Why tests with an in-memory DB? The store tests open SQLite with ":memory:" so each test gets a fresh throwaway database — fast, no leftover files.

2.3 Frontend (Astro blog)

Layout:

frontend/
├── src/content/             # the actual posts
│   ├── config.ts            # schema: every post has title, description, pubDate, tags, draft
│   ├── tech/*.mdx           # technical posts
│   └── life/*.mdx           # daily-life posts
├── src/lib/posts.ts         # framework-free helpers (sort, filter drafts, reading time)
├── src/lib/posts.test.ts    # unit tests (vitest)
├── src/layouts/             # page shells
├── src/pages/               # routes (index, /tech, /life, post pages, rss.xml)
├── astro.config.mjs
└── package.json

Key idea: Content Collections. Posts are .mdx files; config.ts defines a schema so Astro validates frontmatter (the --- block at the top of each post) at build time.

Run it:

cd frontend
npm install
npm test            # vitest — tests the pure helper functions
npm run dev         # local preview at http://localhost:4321
npm run build       # outputs static HTML into dist/

Why test only src/lib? The helpers (sorting posts, hiding drafts, reading-time) are plain functions with no Astro dependency, so they’re trivial and fast to unit-test. The pages themselves are verified by npm run build succeeding.

2.4 First push

Both repos were empty (no branches), so we created main and pushed:

git checkout -b main
git add -A
git commit -m "Scaffold ..."
git push -u origin main

3. Phase 2 — The UI redesign

You liked the look of lehoangdung.blog. We studied it (it’s a Next.js + Tailwind site) and borrowed the visual language without changing your stack:

  • Added Tailwind CSS v4 (via @tailwindcss/vite) + Inter font.
  • Light/dark mode with a no-flash toggle (a tiny inline <script> sets the theme before the page paints, so there’s no white flash in dark mode).
  • Gradient hero, sticky blurred header, animated post cards, tag pills.
  • A PostLayout with prose typography and dual-theme code highlighting (Shiki renders light + dark, CSS swaps them).
  • Extras: reading-time, custom 404, gradient favicon.

Everything stayed in Astro/MDX — only styling/markup changed. Verified with npm test (6 passing) and npm run build (clean).


4. Phase 3 — Connecting to the server (SSH)

You gave root SSH access: ssh root@109.123.229.225.

4.1 Why we didn’t just use the root password everywhere

Using a password in automation is fragile and insecure (it can’t be safely stored in GitHub, and it ends up in logs). The professional approach is SSH key authentication:

  • A key pair = a private key (secret, stays on your machine / in GitHub Secrets) + a public key (safe to share, goes on the server).
  • The server trusts anyone holding the matching private key — no password typed.

4.2 Generating the deploy key

ssh-keygen -t ed25519 -N "" -C "github-actions-blog-deploy" -f .deploy/deploy_key
  • ed25519 = a modern, small, fast key type.
  • -N "" = no passphrase (needed so CI can use it unattended).
  • This created .deploy/deploy_key (private) and .deploy/deploy_key.pub (public).
  • .deploy/ lives outside both git repos, so the private key can never be accidentally committed.

4.3 Installing the public key on the server

We used sshpass (a helper that feeds the password to ssh non-interactively) once to bootstrap — append the public key to the server, then never rely on the password again:

brew install hudochenkov/sshpass/sshpass
# append our public key so key-login works:
sshpass -p '***' ssh root@109.123.229.225 \
  "echo 'ssh-ed25519 AAAA... github-actions-blog-deploy' >> ~/.ssh/authorized_keys"

After that, every connection used the key:

ssh -i .deploy/deploy_key -o IdentitiesOnly=yes deploy@109.123.229.225

(IdentitiesOnly=yes = “only try this key, not every key in my agent”.)

4.4 Server inventory — and the surprise

We inspected the server before touching anything:

ssh ... 'cat /etc/os-release; uname -m; free -h; docker ps'

Findings:

  • Ubuntu 24.04, x86_64, ~8 GB RAM, Docker already installed.
  • The server was NOT empty. docker ps revealed ~17 containers: chamee-* (postgres/redis/minio/caddy), elearning-*, laws-*, telebot, botbot.
  • chamee-caddy already owned ports 80 and 443.

➡️ This changed the plan. Installing our own web server on :80 would have collided with and could have taken down those production apps. So we chose isolation.

4.5 Creating a scoped deploy user

Instead of deploying as root, we made a limited deploy user:

useradd -m -s /bin/bash deploy
usermod -aG docker deploy          # can run docker (needed to deploy)
mkdir -p /home/deploy/.ssh
echo '<public key>' >> /home/deploy/.ssh/authorized_keys
mkdir -p /opt/blog/web /opt/blog/data
chown -R deploy:deploy /opt/blog   # owns only its own directory

Now CI logs in as deploy, not root.


5. Phase 4 — Deploying with Docker

Isolation strategy: the blog runs as its own Docker stack on high ports (8080 web, 8090 api) so it never touches the shared :80/:443 or the other apps.

5.1 The backend Dockerfile (multi-stage)

# build stage: full Go toolchain
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /api ./cmd/api

# runtime stage: nearly empty image, just the binary
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /api /api
ENTRYPOINT ["/api"]

Why two stages? The first stage has the whole Go compiler (~300 MB). The second copies only the compiled binary into a distroless image (no shell, no package manager — tiny and secure). CGO_ENABLED=0 makes the binary fully static so it runs in that minimal image.

5.2 docker-compose.yml (/opt/blog/docker-compose.yml)

Defines two services:

  • web: nginx:alpine, serves the static dist/ files (mounted from ./web), published on 8080:80.
  • api: our blog-api:latest image, published on 8090:8080, with env vars (DB_PATH, CORS_ORIGINS) and a volume for the SQLite file.

5.3 nginx.conf

A small config so clean URLs work (/tech/hello-world/ → its index.html) and missing pages return a real 404 with our custom page:

location / { try_files $uri $uri/ $uri.html =404; }
error_page 404 /404.html;

5.4 First manual deploy

Because the server is x86_64 and your Mac is arm64, we built the image on the server (correct architecture):

# ship config + built static site + backend source
scp deploy/docker-compose.yml deploy/nginx.conf deploy@server:/opt/blog/
rsync -az dist/ deploy@server:/opt/blog/web/
rsync -az --exclude '.git' backend/ deploy@server:/opt/blog/_build/

# build + start on the server
ssh deploy@server 'cd /opt/blog && docker build -t blog-api:latest _build && docker compose up -d'

Result: blog-web and blog-api both Up. Verified from the public internet with curl.


6. Phase 5 — CI/CD with GitHub Actions

Goal: every git push to main automatically tests, builds, and deploys.

A GitHub Actions workflow is a YAML file in .github/workflows/. GitHub runs it on a fresh cloud VM whenever the trigger fires.

6.1 Backend workflow (backend/.github/workflows/deploy.yml)

Two jobs:

  1. testgo vet ./... + go test ./....
  2. deploy (only if tests pass):
    • docker build the image,
    • docker save | gzip it into a tarball,
    • write the SSH key from a secret, scp the tarball to the server,
    • ssh in and docker load + docker compose up -d api.

Why ship a tarball instead of a registry? It avoids needing a container registry + login. The image is small, so save → scp → load is simple and self-contained.

6.2 Frontend workflow (frontend/.github/workflows/deploy.yml)

One job: npm cinpm testnpm run buildrsync dist/ to /opt/blog/web on the server. Static files, no container rebuild needed.

6.3 GitHub Secrets (the part you do in the browser)

Secrets are encrypted values stored by GitHub, never committed to the repo. The workflow reads them as ${{ secrets.NAME }}.

In each repo → Settings → Secrets and variables → Actions → New repository secret:

NameValue
SSH_HOST109.123.229.225
SSH_USERdeploy
SSH_KEYcontents of .deploy/deploy_key (the private key)

The deploy step fails until these exist (it can’t authenticate). Once added, re-run the workflow or push again.


7. Phase 6 — Domain + DNS + HTTPS

You owned hoongan.art (registered at Namecheap). Goal: serve the blog at https://hoongan.art with no port number and a valid certificate.

7.1 DNS — pointing the name at the server

DNS maps a domain name → an IP address. We added one record at Namecheap (Advanced DNS → Host Records):

TypeHostValue
A@109.123.229.225

(@ = the bare domain hoongan.art. We also had to delete Namecheap’s default URL-redirect/parking record on @, which otherwise overrides the A record.)

Check propagation from anywhere:

dig +short hoongan.art            # should print 109.123.229.225
dig +short @8.8.8.8 hoongan.art   # check via Google's resolver

7.2 Routing through the existing Caddy (not a second one)

Since chamee-caddy owns :443, we made the blog reachable by it:

  1. Joined the blog containers to Caddy’s Docker network so Caddy can find them by name. In docker-compose.yml:
    networks: [default, chamee]
    networks:
      chamee:
        external: true
        name: chamee-devops_chamee-net
    
  2. Appended a vhost block to the shared Caddyfile (/home/chamee/.../shared/caddy/Caddyfile) — carefully: back up, append, validate, reload:
    hoongan.art {
        encode zstd gzip
        @api path /api/* /healthz
        reverse_proxy @api blog-api:8080     # API traffic → Go
        reverse_proxy blog-web:80            # everything else → static site
    }
    
    cp Caddyfile Caddyfile.bak.$(date +%s)               # backup first!
    docker exec chamee-caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
    docker exec chamee-caddy caddy reload  --config /etc/caddy/Caddyfile
    

7.3 HTTPS — automatic, via Let’s Encrypt

Caddy does this automatically: on the first request for hoongan.art, it talks to Let’s Encrypt, proves it controls the domain (an ACME challenge), and installs a free certificate that auto-renews. No manual cert work.

Verify:

echo | openssl s_client -servername hoongan.art -connect hoongan.art:443 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
# subject=CN = hoongan.art   issuer=Let's Encrypt   (valid 90 days)

8. The bugs we hit & how we fixed them

Real deployments hit snags. Here’s each one and the lesson.

Bug 1 — Astro build crashed on @astrojs/sitemap

A version incompatibility made the sitemap integration throw at build time. Fix: removed sitemap (it’s optional Phase-6 polish); build went green. Re-add later once the domain is final.

Bug 2 — API crash-looped: out of memory (14)

The Go container ran as the distroless nonroot user (uid 65532), but the mounted data/ directory was owned by deploy, so SQLite couldn’t create the database file. SQLite reports this open-failure with the misleading text “out of memory (14)”. Fix: chown 65532:65532 /opt/blog/data so the container user can write. Lesson: container file permissions are about the container’s user, not the host user — and error messages can lie.

Bug 3 — https://109.123.229.225 gave SSL error

There’s no certificate for a raw IP, and :443 belongs to the other apps’ Caddy. Fix: the correct pre-domain URL was http://109.123.229.225:8080 (note http + the port). HTTPS only works via the domain.

Bug 4 — ERR_CERT_COMMON_NAME_INVALID even after the cert was issued

Two layers:

  • First time: DNS hadn’t fully propagated, so Let’s Encrypt’s validation briefly reached the old Shopify parking IP (23.227.38.65) and was rejected (HTTP 409). Caddy then backed off. Fix: once dig @8.8.8.8 and @1.1.1.1 both returned the right IP, we restarted Caddy to force a fresh attempt — cert issued in ~5 s.
  • Then on your machine: your Mac/ISP still had stale DNS cached + an HSTS lock from the old host, so Chrome kept hitting the wrong server. Fix (client side): flush DNS + clear Chrome’s HSTS entry:
    sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
    
    Chrome: chrome://net-internals/#hsts → delete hoongan.art; chrome://net-internals/#dns → clear host cache. Proof it was local: the site loaded fine on a phone with mobile data (different resolver). Lesson: “the cert is wrong” is often really “I’m reaching the wrong server because of cached DNS/HSTS.” Always confirm from an independent network (curl from elsewhere, or your phone).

9. Cheat sheet — day-to-day commands

Write a new post

cd frontend
# create src/content/tech/my-post.mdx  (or life/)
npm run dev          # preview at http://localhost:4321
git add -A && git commit -m "post: my-post" && git push   # auto-deploys

Run things locally

# frontend
cd frontend && npm test && npm run dev

# backend
cd backend && make test && make run     # http://localhost:8080

SSH into the server

ssh -i .deploy/deploy_key -o IdentitiesOnly=yes deploy@109.123.229.225

Check the live stack

ssh ... 'cd /opt/blog && docker compose ps'        # container status
ssh ... 'docker logs blog-api --tail 30'           # API logs
curl https://hoongan.art/healthz                   # API health

Inspect the cert / DNS

dig +short hoongan.art
echo | openssl s_client -servername hoongan.art -connect hoongan.art:443 2>/dev/null \
  | openssl x509 -noout -subject -dates

Restart the blog (if ever needed)

ssh ... 'cd /opt/blog && docker compose restart'

10. Glossary

TermPlain meaning
Static sitePre-built HTML/CSS files; no server logic to render a page. Fast & cheap.
MDXMarkdown that can also contain components. Your posts.
SQLiteA database that’s just a single file. No separate DB server.
CGOGo calling C code. We disable it (CGO_ENABLED=0) for a static, portable binary.
Docker image / containerImage = a packaged app + its environment. Container = a running instance of an image.
docker composeDefines/runs multiple containers together from one YAML file.
Reverse proxyA front-door server that receives all requests and forwards each to the right backend (here: Caddy).
CaddyA web server/reverse proxy that gets HTTPS certificates automatically.
CI/CDContinuous Integration / Deployment — automated test + deploy on every push.
GitHub ActionsGitHub’s built-in CI/CD that runs your workflow YAML on a cloud VM.
SecretAn encrypted value stored in GitHub, injected into a workflow at run time, never in code.
SSH key pairPrivate key (secret) + public key (on server) for password-less login.
DNS / A recordThe phonebook of the internet; an A record maps a domain to an IPv4 address.
TTLHow long a DNS answer may be cached before re-checking.
PropagationThe time for a DNS change to spread across the world’s resolvers.
Let’s Encrypt / ACMEFree certificate authority; ACME is the protocol Caddy uses to prove domain ownership and get a cert.
HSTSA header telling browsers “always use HTTPS for this domain” — can cause sticky errors if a wrong cert was seen.
distrolessA minimal container base image with no shell/package manager — small and secure.

Generated as a record of the full build & deploy session.