Ghost 블로그를 CLI에서 Docker로 옮기고, Analytics까지 붙였습니다

Share
Ghost 블로그를 CLI에서 Docker로 옮기고, Analytics까지 붙였습니다

처음에는 단순히 “Ghost 블로그에 조회수 좀 보고 싶다” 정도였습니다.

그런데 막상 들여다보니 일이 조금 커졌습니다.

Ghost는 CLI 방식으로 떠 있었고, DB는 MariaDB였습니다.
블로그는 잘 돌아가고 있었지만, Ghost 6에서 내장 Web Analytics를 제대로 쓰려면 Docker 기반 구성이 훨씬 자연스러워 보였습니다.

그래서 결국 오늘 한 일은 이겁니다.

Ghost CLI로 돌던 블로그를 Podman Compose 기반 Docker 구조로 옮기고,
MariaDB를 MySQL 8로 이전하고,
마지막으로 Tinybird 기반 Ghost Web Analytics까지 붙였습니다.

대단한 기능을 만든 건 아니지만, 블로그 운영의 밑바닥을 다시 다진 하루였습니다.


시작 상태 확인

먼저 현재 Ghost가 어떻게 떠 있는지 확인했습니다.

처음에는 당연히 /var/www/ghost에서 Ghost CLI 인스턴스가 잡힐 줄 알았습니다.

cd /var/www/ghost
ghost ls

그런데 결과는 이랬습니다.

No installed ghost instances found

순간 조금 찝찝했습니다.

“어? 블로그는 잘 떠 있는데 Ghost CLI 인스턴스가 없다고?”

그래서 프로세스와 systemd를 확인했습니다.

ps -ef | grep -i ghost | grep -v grep
systemctl list-units --type=service | grep -i ghost
sudo ss -lntp | grep -E '2368|ghost|node'

확인해보니 Ghost는 이렇게 떠 있었습니다.

ghost run
/usr/bin/node current/index.js
ghost-blog.service
127.0.0.1:2368

그리고 Caddy는 blog.goodtek.xyz127.0.0.1:2368로 프록시하고 있었습니다.

blog.goodtek.xyz {
        reverse_proxy 127.0.0.1:2368
}

즉 기존 구조는 이랬습니다.

blog.goodtek.xyz
→ Caddy
→ 127.0.0.1:2368
→ systemd Ghost
→ MariaDB

Ghost 설정 확인

실제 설정 파일을 열어보니 DB 정보와 content 경로가 확인됐습니다.

cat /var/www/ghost/config.production.json

핵심은 이랬습니다.

{
  "url": "https://blog.goodtek.xyz",
  "server": {
    "port": 2368,
    "host": "127.0.0.1"
  },
  "database": {
    "client": "mysql",
    "connection": {
      "host": "localhost",
      "user": "ghostuser",
      "database": "ghost_prod"
    }
  },
  "paths": {
    "contentPath": "/var/www/ghost/content"
  }
}

여기서 중요한 건 두 가지였습니다.

DB 이름은 ghost_prod
콘텐츠 경로는 /var/www/ghost/content

즉 게시글 본문은 DB에 있고, 이미지와 테마 파일은 content 디렉터리에 있었습니다.


DB가 MySQL이 아니라 MariaDB였습니다

Ghost 설정에는 client: mysql이라고 되어 있었지만, 실제 DB 버전을 확인해보니 MariaDB였습니다.

mysql -u ghostuser -p -e "SELECT VERSION();"

결과는 이랬습니다.

10.5.29-MariaDB

Ghost가 돌아가고는 있었지만, Docker로 이관하는 김에 MySQL 8로 정리하는 게 맞다고 판단했습니다.

그래서 목표 구조를 이렇게 잡았습니다.

기존 MariaDB 10.5
→ mysqldump
→ Docker MySQL 8
→ Docker Ghost

백업부터 했습니다

이런 작업은 결국 “되돌릴 수 있느냐”가 제일 중요합니다.

먼저 content를 백업했습니다.

sudo mkdir -p /root/ghost-migration-backup

sudo tar -czvf /root/ghost-migration-backup/ghost-content-$(date +%Y%m%d-%H%M%S).tar.gz \
  -C /var/www/ghost content

DB도 dump를 뜨려고 했는데, 처음에는 에러가 났습니다.

mysqldump -u ghostuser -p \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --default-character-set=utf8mb4 \
  ghost_prod > /tmp/ghost_prod.sql

에러는 이랬습니다.

Cannot load from mysql.proc. The table is probably corrupted

Ghost 이관에는 routines가 꼭 필요한 구조는 아니라고 보고, --routines, --triggers를 빼고 다시 dump를 떴습니다.

mysqldump -u ghostuser -p \
  --single-transaction \
  --quick \
  --default-character-set=utf8mb4 \
  ghost_prod > /tmp/ghost_prod.sql

이번에는 성공했습니다.

/tmp/ghost_prod.sql 1.6M

그리고 백업 폴더로 옮겼습니다.

sudo mv /tmp/ghost_prod.sql /root/ghost-migration-backup/ghost_prod-20260601-101756.sql

백업 파일은 이렇게 확보됐습니다.

ghost-content-20260601-101546.tar.gz  52M
ghost_prod-20260601-101756.sql        1.6M

SQL 파일 안에 posts 테이블이 있는지도 확인했습니다.

sudo grep -n "CREATE TABLE.*posts" /root/ghost-migration-backup/ghost_prod-20260601-101756.sql | head
CREATE TABLE `posts`
CREATE TABLE `posts_authors`
CREATE TABLE `posts_meta`
CREATE TABLE `posts_tags`

여기까지 보고 나서야 안심이 됐습니다.


Docker 작업 경로는 /home/web/goodtek/ghost-blog

저는 기존 goodtek 관련 서비스들을 /home/web/goodtek 아래에 모아두고 있어서, Ghost도 이쪽으로 옮겼습니다.

cd /home/web/goodtek
mkdir -p ghost-blog
cd ghost-blog

mkdir -p content mysql backup

백업 파일도 작업 폴더로 복사했습니다.

sudo cp /root/ghost-migration-backup/ghost-content-20260601-101546.tar.gz ./backup/
sudo cp /root/ghost-migration-backup/ghost_prod-20260601-101756.sql ./backup/
sudo chown -R web:web ./backup

그리고 content를 복원했습니다.

tar -xzvf backup/ghost-content-20260601-101546.tar.gz

복원 후 확인해보니:

content 전체: 83M
images: 52M
themes: 1.4M

이미지와 테마까지 잘 들어와 있었습니다.


.envdocker-compose.yml 작성

DB 비밀번호는 기존 것을 그대로 쓰지 않고 새로 만들었습니다.

cat > .env <<EOF
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')
MYSQL_DATABASE=ghost_prod
MYSQL_USER=ghostuser
MYSQL_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')
GHOST_URL=https://blog.goodtek.xyz
EOF

chmod 600 .env

처음에는 테스트를 위해 Ghost를 2369 포트로 띄웠습니다.
기존 운영 Ghost가 2368을 쓰고 있었기 때문입니다.

services:
  mysql:
    image: docker.io/library/mysql:8.0
    container_name: ghost-blog-mysql
    restart: unless-stopped
    env_file:
      - .env
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./mysql:/var/lib/mysql
    networks:
      - ghost-blog-net

  ghost:
    image: docker.io/library/ghost:6
    container_name: ghost-blog-app
    restart: unless-stopped
    env_file:
      - .env
    environment:
      url: ${GHOST_URL}
      database__client: mysql
      database__connection__host: mysql
      database__connection__user: ${MYSQL_USER}
      database__connection__password: ${MYSQL_PASSWORD}
      database__connection__database: ${MYSQL_DATABASE}
      server__host: 0.0.0.0
      server__port: 2368
    ports:
      - "127.0.0.1:2369:2368"
    volumes:
      - ./content:/var/lib/ghost/content
    depends_on:
      - mysql
    networks:
      - ghost-blog-net

networks:
  ghost-blog-net:
    driver: bridge

MySQL 8 컨테이너에 DB 복원

먼저 MySQL만 올렸습니다.

podman-compose up -d mysql

접속 확인:

set -a
source .env
set +a

podman exec ghost-blog-mysql \
  mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" \
  -e "SELECT VERSION(); SHOW DATABASES;"

결과는 MySQL 8이었습니다.

8.0.46
ghost_prod

이제 기존 MariaDB dump를 MySQL 8에 복원했습니다.

podman exec -i ghost-blog-mysql \
  mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" \
  < backup/ghost_prod-20260601-101756.sql

테이블도 확인했습니다.

podman exec ghost-blog-mysql \
  mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" \
  -e "SHOW TABLES;" | wc -l
82

게시글 수 역시 기존 MariaDB와 Docker MySQL이 일치했습니다.

SELECT status, COUNT(*) AS cnt FROM posts GROUP BY status;
published 7

Ghost 컨테이너 실행 중 만난 첫 번째 문제: 깨진 테마 링크

Ghost 컨테이너를 실행했더니 처음에는 바로 죽었습니다.

로그는 이랬습니다.

chown: cannot dereference '/var/lib/ghost/content/themes/source': No such file or directory
chown: cannot dereference '/var/lib/ghost/content/themes/casper': No such file or directory

확인해보니 content/themes 안에 깨진 심볼릭 링크가 있었습니다.

ls -al content/themes
casper -> /var/www/ghost/current/content/themes/casper
source -> /var/www/ghost/current/content/themes/source
edition

기존 CLI 설치에서는 유효했지만, Docker 안에서는 /var/www/ghost/current/... 경로가 없으니 깨진 링크가 된 겁니다.

실제로 쓰는 테마는 edition이었기 때문에, 깨진 링크만 삭제했습니다.

sudo rm content/themes/casper
sudo rm content/themes/source

그 후 Ghost 컨테이너가 정상 기동됐습니다.

Ghost is running in production...
Database is in a ready state.
Ghost database ready

테스트 확인

테스트 포트 2369로 API 응답을 확인했습니다.

curl -s \
  -H "Host: blog.goodtek.xyz" \
  -H "X-Forwarded-Proto: https" \
  http://127.0.0.1:2369/ghost/api/content/posts/?key=invalid \
  | head -c 300

응답은 JSON이었습니다.

{"errors":[{"message":"Unknown Content API Key"}]}

이건 오히려 정상입니다.
Ghost API가 살아 있고, DB도 읽고 있다는 뜻입니다.

이미지도 확인했습니다.

curl -I \
  -H "Host: blog.goodtek.xyz" \
  -H "X-Forwarded-Proto: https" \
  http://127.0.0.1:2369/content/images/2026/05/blog-thumbnail-1.png
HTTP/1.1 200 OK
Content-Type: image/png

여기까지 확인하고 나서 운영 전환으로 넘어갔습니다.


운영 포트를 2368로 전환

처음에는 Docker Ghost를 계속 2369로 두고 Caddy만 바꿀까도 생각했습니다.
하지만 최종적으로는 더 깔끔하게 2368을 그대로 쓰기로 했습니다.

순서는 이렇게 진행했습니다.

1. Docker compose 백업
2. docker-compose.yml 포트 2369 → 2368 수정
3. Docker Ghost 중지
4. 기존 systemd Ghost 중지
5. Docker Ghost를 2368로 기동
6. Caddy는 그대로 유지

포트 수정:

sed -i 's|127.0.0.1:2369:2368|127.0.0.1:2368:2368|' docker-compose.yml

기존 systemd Ghost 중지:

sudo systemctl stop ghost-blog.service

포트 확인:

sudo ss -lntp | grep 2368 || echo "2368 is free"

Docker Ghost 기동:

podman-compose up -d ghost

최종 확인:

curl -I https://blog.goodtek.xyz
curl -I https://blog.goodtek.xyz/ghost/

둘 다 정상 응답이 나왔습니다.

HTTP/2 200

이제 구조는 이렇게 바뀌었습니다.

blog.goodtek.xyz
→ Caddy
→ 127.0.0.1:2368
→ Docker Ghost
→ Docker MySQL 8

Gmail SMTP 설정 복구

전환 후 로그인은 잘 됐지만, 메일 발송이 실패했습니다.

Ghost Admin에서 보인 메시지는 이랬습니다.

Failed to send email. Please check your site configuration and try again.

원인은 간단했습니다.
기존 config.production.json에는 Gmail SMTP 인증 정보가 있었는데, Docker compose에는 auth.user, auth.pass가 빠져 있었습니다.

.env에 Gmail 앱 비밀번호를 추가했습니다.

read -s -p "GMAIL_APP_PASSWORD: " GMAIL_APP_PASSWORD
echo
printf '\nGMAIL_USER=goodtek.xyz@gmail.com\nGMAIL_APP_PASSWORD=%s\n' "$GMAIL_APP_PASSWORD" >> .env

그리고 compose에 추가했습니다.

mail__options__auth__user: ${GMAIL_USER}
mail__options__auth__pass: ${GMAIL_APP_PASSWORD}

Ghost가 실제로 설정을 읽는지도 확인했습니다.

podman exec ghost-blog-app sh -c 'cd /var/lib/ghost/current && node -e "const config=require(\"./core/shared/config\"); console.log(config.get(\"mail\"))"'

결과:

transport: SMTP
from: Goodtek <goodtek.xyz@gmail.com>
host: smtp.gmail.com
port: 587
secure: false
auth: ...

그 후 메일 발송도 정상 동작했습니다.


기존 CLI 정리

Docker 전환이 끝난 뒤에는 기존 systemd Ghost가 다시 살아나지 않도록 비활성화했습니다.

sudo systemctl disable ghost-blog.service

상태는 이렇게 됐습니다.

ghost-blog.service: disabled
Active: inactive (dead)

이후 기존 /var/www/ghost도 삭제했습니다.

sudo rm -rf /var/www/ghost

systemd 서비스 파일도 삭제했습니다.

sudo rm -f /etc/systemd/system/ghost-blog.service
sudo systemctl daemon-reload

마지막으로 Ghost CLI도 제거했습니다.

sudo npm uninstall -g ghost-cli

그런데 /usr/local/bin/ghost 심볼릭 링크가 남아 있어서 직접 정리했습니다.

sudo rm -f /usr/local/bin/ghost
sudo rm -rf /usr/local/lib/node_modules/ghost-cli

확인 결과:

ghost command removed
ghost-cli node_modules removed

이제 Ghost CLI 기반 흔적은 정리됐습니다.


Ghost Analytics 붙이기

Docker 전환을 한 이유 중 하나가 Ghost 내장 Web Analytics였습니다.

Ghost Admin에 들어가보니 Analytics 메뉴는 보였지만, Web analytics는 Tinybird 설정이 필요하다고 나왔습니다.

Web analytics
Cookie-free, first party traffic analytics for your site
Web analytics in Ghost is powered by Tinybird and requires configuration

그래서 Tinybird를 붙였습니다.


Tinybird 공식 구성 확인

Ghost 공식 Docker repo를 /tmp에 받아서 analytics 관련 compose 구성을 확인했습니다.

cd /tmp
git clone https://github.com/TryGhost/ghost-docker.git ghost-docker
cd ghost-docker

grep -R "tinybird-login\|traffic-analytics\|tinybird-sync\|tinybird-deploy" -n .

필요한 서비스는 이 네 가지였습니다.

traffic-analytics
tinybird-login
tinybird-sync
tinybird-deploy

공식 compose를 참고해서 현재 운영 compose에 analytics 관련 서비스를 추가했습니다.

그리고 Tinybird helper 파일도 복사했습니다.

cd /home/web/goodtek/ghost-blog
cp -R /tmp/ghost-docker/tinybird ./tinybird

.env에는 Tinybird 값을 넣을 자리를 만들었습니다.

cat >> .env <<'EOF'

# Ghost Web Analytics / Tinybird
TINYBIRD_API_URL=https://api.tinybird.co
TINYBIRD_ADMIN_TOKEN=
TINYBIRD_WORKSPACE_ID=
TINYBIRD_TRACKER_TOKEN=
EOF

Tinybird 로그인과 배포

Tinybird 로그인은 서버 컨테이너 안에서 진행되기 때문에, 처음에는 localhost callback에서 막혔습니다.

Tinybird가 이런 주소를 열려고 했습니다.

http://localhost:49160/...

문제는 이 localhost가 제 Mac이 아니라 서버 쪽 컨테이너 기준이라는 점이었습니다.

그래서 SSH 터널을 열었습니다.

ssh -i "키파일경로" \
  -L 49160:127.0.0.1:49160 \
  web@서버IP

그 후 Tinybird 로그인에 성공했습니다.

Workspace: goodtek_blog
User: goodtek.xyz@gmail.com
Host: https://api.ap-east-1.aws.tinybird.co
Authentication successful

API URL도 리전 URL로 바꿨습니다.

sed -i 's|^TINYBIRD_API_URL=.*|TINYBIRD_API_URL=https://api.ap-east-1.aws.tinybird.co|' .env

이후 Tinybird sync를 실행했습니다.

podman-compose run --rm tinybird-sync

결과:

Tinybird files synced into shared volume.

그리고 deploy를 실행했습니다.

podman-compose run --rm tinybird-deploy

배포 결과:

Deployment #1 is live!

Tinybird datasource, pipe, endpoint, token이 생성됐습니다.


Tinybird 토큰 반영

토큰은 아래 명령어로 가져왔습니다.

podman-compose run --rm tinybird-login get-tokens

출력된 값은 .env에 반영했습니다.

TINYBIRD_API_URL
TINYBIRD_WORKSPACE_ID
TINYBIRD_ADMIN_TOKEN
TINYBIRD_TRACKER_TOKEN

그리고 Ghost가 Tinybird 설정을 실제로 읽는지도 확인했습니다.

podman exec ghost-blog-app sh -c 'cd /var/lib/ghost/current && node - <<'"'"'NODE'"'"'
const config = require("./core/shared/config");
const keys = [
  "tinybird:tracker:endpoint",
  "tinybird:adminToken",
  "tinybird:workspaceId",
  "tinybird:tracker:datasource",
  "tinybird:stats:endpoint"
];
for (const k of keys) {
  const v = config.get(k);
  console.log(k + " = " + (v ? String(v).slice(0, 12) + "..." : ""));
}
NODE'

결과는 모두 값이 들어가 있었습니다.

tinybird:tracker:endpoint = https://blog...
tinybird:adminToken = ...
tinybird:workspaceId = ...
tinybird:tracker:datasource = analytics_ev...
tinybird:stats:endpoint = https://api....

traffic-analytics 컨테이너 추가

Ghost Web Analytics는 브라우저에서 바로 Tinybird로 쏘는 게 아니라, traffic-analytics 서비스가 중간에서 받아서 Tinybird로 넘기는 구조입니다.

처음에는 3000 포트를 쓸까 했지만, 3000은 다른 서비스와 충돌할 가능성이 높아서 13000으로 열었습니다.

127.0.0.1:13000
→ traffic-analytics:3000

포트 확인:

sudo ss -lntp | grep ':13000' || echo "13000 is free"

compose에는 이렇게 추가했습니다.

traffic-analytics:
  image: docker.io/ghost/traffic-analytics:1.0.244
  container_name: ghost-blog-traffic-analytics
  restart: unless-stopped
  ports:
    - "127.0.0.1:13000:3000"
  environment:
    NODE_ENV: production
    PROXY_TARGET: ${TINYBIRD_API_URL}/v0/events
    SALT_STORE_TYPE: file
    SALT_STORE_FILE_PATH: /data/salts.json
    TINYBIRD_TRACKER_TOKEN: ${TINYBIRD_TRACKER_TOKEN}
    LOG_LEVEL: info
  volumes:
    - traffic_analytics_data:/data
  networks:
    - ghost-blog-net

실행:

podman-compose up -d traffic-analytics

확인:

podman ps --filter name=ghost-blog-traffic-analytics
127.0.0.1:13000->3000/tcp

Caddy에 Analytics 프록시 추가

Caddy에서 Ghost Analytics 요청만 traffic-analytics로 보내도록 추가했습니다.

기존:

blog.goodtek.xyz {
        reverse_proxy 127.0.0.1:2368
}

변경 후:

blog.goodtek.xyz {
        handle_path /.ghost/analytics/* {
                reverse_proxy 127.0.0.1:13000
        }

        reverse_proxy 127.0.0.1:2368
}

설정 검증:

sudo caddy validate --config /etc/caddy/Caddyfile

결과:

Valid configuration

그리고 reload:

sudo systemctl reload caddy

프록시 연결도 확인했습니다.

curl -I https://blog.goodtek.xyz/.ghost/analytics/api/v1/page_hit

그리고 traffic-analytics 로그에서 요청이 들어오는 것을 확인했습니다.

IncomingRequest
requestMethod: HEAD
requestUrl: /api/v1/page_hit

실제 방문 이벤트 수집 확인

마지막으로 브라우저에서 블로그를 열고 traffic-analytics 로그를 확인했습니다.

podman logs --tail 120 ghost-blog-traffic-analytics

드디어 실제 POST 이벤트가 찍혔습니다.

requestMethod: POST
requestUrl: /api/v1/page_hit?name=analytics_events
referer: https://blog.goodtek.xyz/
source: https://api.ap-east-1.aws.tinybird.co/v0/events
message: response received

이걸 보고 Analytics 수집이 정상 연결됐다고 판단했습니다.

흐름은 이렇게 됩니다.

방문자 브라우저
→ blog.goodtek.xyz/.ghost/analytics/api/v1/page_hit
→ Caddy
→ 127.0.0.1:13000
→ traffic-analytics
→ Tinybird ap-east-1
→ Ghost Admin Analytics

Ghost Admin에서도 Analytics 화면이 보였습니다.

여기까지 오니 “이제 진짜 붙었다”는 느낌이 들었습니다.


Tinybird 비용 확인

Tinybird는 현재 Free plan이었습니다.

화면 기준으로는:

Free plan
1,000 requests/day
10GB Cloud Storage
Upgrade from $25/mo

현재 사용량은 아주 낮았습니다.

8 / 1,000 requests/day
0GB / 10GB storage

초기 블로그 트래픽에서는 당장 비용 걱정은 크지 않아 보입니다.
다만 방문자가 늘면 page hit, analytics 조회 API 등이 request로 쌓일 수 있으니, Usage는 종종 확인할 예정입니다.


최종 구조

최종적으로 goodtek 블로그는 이렇게 바뀌었습니다.

https://blog.goodtek.xyz
→ Caddy
→ 127.0.0.1:2368
→ ghost-blog-app
→ ghost-blog-mysql

Analytics는 별도 흐름입니다.

/.ghost/analytics/*
→ Caddy
→ 127.0.0.1:13000
→ ghost-blog-traffic-analytics
→ Tinybird

현재 운영 컨테이너는 이렇습니다.

ghost-blog-app
ghost-blog-mysql
ghost-blog-traffic-analytics

운영 명령어는 이 정도만 기억하면 됩니다.

cd /home/web/goodtek/ghost-blog

podman-compose ps
podman-compose logs -f ghost
podman-compose logs -f traffic-analytics
podman-compose restart ghost
podman-compose restart traffic-analytics
podman-compose up -d

백업도 남겨두었습니다.

/root/ghost-migration-backup
/home/web/goodtek/ghost-blog/backup
docker-compose.yml.bak-before-analytics-20260601-113938
/etc/caddy/Caddyfile.bak-before-ghost-analytics-20260601-115552

해보니 느낀 점

처음에는 단순히 “조회수 보고 싶다”였습니다.

그런데 막상 해보니 조회수를 보기 위해 필요한 일은 단순하지 않았습니다.

기존 설치 방식 확인,
DB 종류 확인,
백업,
MariaDB에서 MySQL 8로 이전,
content 경로 복원,
테마 심볼릭 링크 문제 해결,
SMTP 복구,
Tinybird 배포,
Caddy 프록시 수정까지 이어졌습니다.

이런 작업은 겉으로 보기에는 아무 변화가 없어 보입니다.

사용자는 그냥 블로그에 접속합니다.
글도 그대로 보입니다.
관리자 로그인도 그대로 됩니다.

하지만 안쪽은 완전히 바뀌었습니다.

CLI로 어딘가에서 돌던 Ghost가 아니라,
이제는 /home/web/goodtek/ghost-blog 아래에서 Podman Compose로 관리됩니다.

DB도 MariaDB가 아니라 MySQL 8입니다.
Analytics도 Ghost Admin 안에서 볼 수 있습니다.

오늘 한 일은 화려한 기능 개발은 아니었습니다.

하지만 goodtek을 계속 쌓아가려면 이런 작업이 필요합니다.
글을 쓰는 것도 중요하지만, 글이 읽히는지 알 수 있는 구조도 필요합니다.
문제가 생겼을 때 다시 띄울 수 있는 구조도 필요합니다.
다음 서버 작업을 할 때 덜 무서운 구조도 필요합니다.

오늘은 블로그를 조금 더 제품답게 만들었습니다.

겉으로는 똑같은 블로그지만,
안쪽은 꽤 많이 단단해졌습니다.

작은 작업 같지만, 이런 작업들이 쌓이면 나중에 훨씬 덜 흔들릴 거라고 믿습니다.

가장 큰 후회는 설치전에 먼저 찾아볼걸... 이었네요.

Read more

AI와 함께 코딩할 때 먼저 배운 것: 만든 것과 만들려던 것을 구분하기

AI와 함께 코딩할 때 먼저 배운 것: 만든 것과 만들려던 것을 구분하기

AI와 함께 코딩하면 계획, 프롬프트, 문서, 실제 코드가 쉽게 섞입니다. 이번 TASK-013에서는 공개 상태 페이지를 만들려 했지만, 실제로 ship된 것은 실시간 대시보드, 알림 테스트 상태 추적, 디자인 정합 작업이었습니다. 이번 글에서는 TASK 이름보다 머지된 코드를 기준으로 사실을 검증한 과정과, Realtime 아키텍처·비동기 상태 모델·공통 계약 관리 등 AI와 함께 개발할 때 놓치기 쉬운 기준들을 정리합니다. Build in Public 관점에서 ‘만들려던 것’과 ‘실제로 만든 것’을 구분하는 방법을 공유합니다.

By ● goodtek
감지 → 판단 → 알림: vibePulse의 심장을 만든 6개의 TASK

감지 → 판단 → 알림: vibePulse의 심장을 만든 6개의 TASK

goodtek이 만들고 있는 vibePulse는 "내가 만든 웹/API가 지금 살아 있는가"를 확인하고, 죽으면 즉시 알려주는 초간단 생존 확인 SaaS입니다. 이번 글은 그 핵심 파이프라인 — 모니터를 만들고(TASK-006), 주기적으로 찌르고(007), 장애를 판단하고(008), 신호가 끊기면 알아채고(009), 알림을 큐에 태워(010), 슬랙·카카오톡으로 보내기까지(011) — 를 한 호흡에 만든 기록입니다.

By ● goodtek