Ghost 보안 업데이트를 하려다가 권한 문제부터 만났습니다
Ghost 보안 업데이트를 하려고 했는데, 첫 번째로 만난 건 보안 문제가 아니라 권한 문제였습니다.
분명 블로그는 정상적으로 돌고 있었습니다.
관리자 페이지도 열리고, 글도 보이고, 서버도 멀쩡해 보였습니다.
그런데 막상 서버에 들어가서 업데이트를 하려고 하니, Ghost는 이렇게 말했습니다.
Permission denied.
서버 운영을 하다 보면 이런 순간이 자주 있습니다.
처음에는 “업데이트 명령어 하나 치면 끝나겠지”라고 생각합니다.
그런데 실제로는 버전 확인, 백업, 권한 확인, 실행 사용자 확인, 서비스 상태 확인까지 하나씩 봐야 합니다.
이번 Ghost 보안 업데이트도 딱 그랬습니다.
goodtek 블로그를 운영하면서 겪은 실제 업데이트 기록을 남겨봅니다.
시작은 Ghost 보안 공지였습니다
Ghost 관리자 화면에 보안 업데이트 안내가 떴습니다.
처음에는 단순한 마이너 업데이트라고 생각했습니다.
그런데 관련 내용을 확인해 보니 그냥 미룰 수 있는 업데이트가 아니었습니다.
이번에 확인한 보안 공지는 아래였습니다.
https://github.com/TryGhost/Ghost/security/advisories/GHSA-62q6-4hv4-vjrw
처음에는 다른 Ghost 보안 이슈와 헷갈렸습니다.
Ghost 관련 CVE 내용을 먼저 봤는데, 제 서버 버전과 정확히 맞는 건 이 GitHub Security Advisory 쪽이었습니다.
핵심 기준은 이거였습니다.
영향 받는 버전: Ghost 4.0.0 이상 ~ 6.36.0 이하
패치 버전: Ghost 6.37.0 이상
그래서 제일 먼저 해야 할 일은 현재 Ghost 버전을 확인하는 것이었습니다.
Ghost-CLI 버전과 Ghost 본체 버전은 다릅니다
서버에 접속해서 먼저 아래 명령어를 실행했습니다.
ghost --version
결과는 이렇게 나왔습니다.
Ghost-CLI version: 1.29.2
여기서 헷갈릴 수 있습니다.
이건 Ghost 블로그 본체 버전이 아닙니다.
Ghost를 관리하는 Ghost-CLI 버전입니다.
실제 블로그를 구동하는 Ghost CMS 버전은 따로 확인해야 합니다.
제 Ghost 설치 경로는 /var/www/ghost였습니다.
cd /var/www/ghost
디렉터리 안을 확인해 보니 이런 구조였습니다.
config.production.json
config.production.json.bak.20260524_224727
content
current
versions
Ghost-CLI 방식으로 설치된 구조가 맞아 보였습니다.
그래서 습관적으로 ghost ls를 실행했습니다.
ghost ls
그런데 결과가 조금 이상했습니다.
No installed ghost instances found
분명 Ghost는 설치되어 있고, 블로그도 정상적으로 운영 중인데 Ghost-CLI는 설치된 인스턴스를 찾지 못한다고 했습니다.
이럴 때는 current가 가리키는 실제 Ghost 패키지 버전을 직접 확인하면 됩니다.
node current/index.js --version
결과는 다음과 같았습니다.
6.36.0
아래 명령어로도 확인할 수 있습니다.
node -p "require('./current/package.json').version"
결과는 동일했습니다.
6.36.0
즉, goodtek 블로그는 정확히 이번 보안 공지의 영향 범위 안에 있었습니다.
현재 버전: 6.36.0
패치 기준: 6.37.0 이상
판정: 업데이트 필요
이제 “나중에 하지 뭐”가 아니라 바로 처리해야 하는 작업이 되었습니다.
업데이트 전에 백업부터 하려고 했습니다
보안 업데이트라고 해도 운영 중인 블로그를 바로 건드리는 건 부담스럽습니다.
최소한 Ghost의 content 디렉터리와 설정 파일은 백업해 두는 게 안전합니다.
처음에는 /var/www 위치에서 바로 백업 파일을 만들려고 했습니다.
cd /var/www
tar czf ghost_backup_$(date +%Y%m%d_%H%M%S).tar.gz ghost/content ghost/config.production.json
그런데 바로 실패했습니다.
tar (child): ghost_backup_20260530_190552.tar.gz: Cannot open: Permission denied
tar (child): Error is not recoverable: exiting now
tar: ghost_backup_20260530_190552.tar.gz: Cannot write: Broken pipe
tar: Child returned status 2
tar: Error is not recoverable: exiting now
에러 메시지는 길지만 원인은 단순했습니다.
현재 로그인한 web 사용자가 /var/www 디렉터리에 백업 파일을 만들 권한이 없었습니다.
이런 경우 명령어가 틀린 게 아닙니다.
제가 지금 어느 사용자로 작업하고 있는지, 그 사용자가 어디에 파일을 만들 수 있는지를 확인해야 합니다.
그래서 백업 파일은 홈 디렉터리 아래에 만드는 방식이 더 안전합니다.
mkdir -p ~/ghost-backups
cd /var/www
tar czf ~/ghost-backups/ghost_backup_$(date +%Y%m%d_%H%M%S).tar.gz ghost/content ghost/config.production.json
ls -lh ~/ghost-backups
만약 읽기 권한까지 막혀 있다면 sudo로 백업하고, 백업 파일 소유권만 다시 제 사용자로 돌리면 됩니다.
sudo mkdir -p /home/web/ghost-backups
sudo tar czf /home/web/ghost-backups/ghost_backup_$(date +%Y%m%d_%H%M%S).tar.gz \
-C /var/www \
ghost/content ghost/config.production.json
sudo chown -R web:web /home/web/ghost-backups
ls -lh /home/web/ghost-backups
백업을 해두면 마음이 조금 편해집니다.
운영 서버에서 업데이트할 때는 “아마 괜찮겠지”보다 “돌아갈 수 있는 길이 있다”가 훨씬 중요합니다.
ghost update를 실행했더니 권한 오류가 났습니다
이제 Ghost 설치 디렉터리로 이동해서 업데이트를 실행했습니다.
cd /var/www/ghost
ghost update
처음에는 Ghost-CLI 버전이 오래됐다는 경고가 나왔습니다.
You are running an outdated version of Ghost-CLI.
It is recommended that you upgrade before continuing.
Run `npm install -g ghost-cli@latest` to upgrade.
이건 일단 경고였습니다.
진짜 문제는 그 아래였습니다.
An error occurred.
Message: 'EACCES: permission denied, open '/var/www/ghost/.ghost-cli''
업데이트가 /var/www/ghost/.ghost-cli 파일을 열거나 수정하려다가 권한 문제로 실패한 것입니다.
여기서 자연스럽게 이런 생각이 듭니다.
sudo ghost update
그런데 이건 조심해야 합니다.
Ghost는 특정 사용자 권한으로 설치되고 실행됩니다.
그런데 무작정 sudo ghost update를 해버리면 일부 파일이 root 소유로 바뀔 수 있습니다.
그러면 지금 문제 하나는 넘어갈 수 있어도, 다음 업데이트 때 더 복잡한 권한 문제가 생길 수 있습니다.
그래서 바로 sudo ghost update를 하지 않고, 먼저 Ghost를 누가 설치했고 누가 실행하고 있는지 확인했습니다.
Ghost 설치 소유자를 확인했습니다
아래 명령어로 현재 사용자, 디렉터리 소유자, Ghost 프로세스, systemd 서비스명을 확인했습니다.
cd /var/www/ghost
echo "== current user =="
whoami
id
echo "== ghost directory owner =="
ls -ld /var/www/ghost
ls -al /var/www/ghost | head -30
echo "== ghost-cli file =="
ls -al /var/www/ghost/.ghost-cli
echo "== content ownership =="
ls -ld /var/www/ghost/content
ls -al /var/www/ghost/content | head -30
echo "== current symlink =="
ls -al /var/www/ghost/current
readlink -f /var/www/ghost/current
echo "== ghost process =="
ps -ef | grep '[g]host'
echo "== systemd service =="
systemctl list-units --type=service | grep ghost
결과를 보니 구조가 분명했습니다.
== current user ==
web
uid=54323(web) gid=54325(web) groups=54325(web),973(ghost),974(blog_goodtek)
== ghost directory owner ==
drwxrwxr-x. 4 ghost ghost 150 May 25 12:30 /var/www/ghost
-rw-r--r--. 1 ghost ghost 792 May 24 23:11 config.production.json
drwxr-xr-x. 11 ghost ghost 126 May 7 15:49 content
lrwxrwxrwx. 1 ghost ghost 30 May 7 15:50 current -> /var/www/ghost/versions/6.36.0
-rw-r--r--. 1 ghost ghost 113 May 7 15:51 .ghost-cli
drwxr-xr-x. 3 ghost ghost 20 May 7 15:49 versions
== ghost-cli file ==
-rw-r--r--. 1 ghost ghost 113 May 7 15:51 /var/www/ghost/.ghost-cli
== content ownership ==
drwxr-xr-x. 11 ghost ghost 126 May 7 15:49 /var/www/ghost/content
== current symlink ==
/var/www/ghost/versions/6.36.0
== ghost process ==
ghost 3624703 1 0 May25 ? 00:00:00 ghost run
ghost 3624726 3624703 0 May25 ? 00:03:31 /usr/bin/node current/index.js
== systemd service ==
ghost-blog.service loaded active running Ghost Blog for goodtek
정리하면 이랬습니다.
현재 로그인 사용자: web
Ghost 설치 소유자: ghost
Ghost 실행 사용자: ghost
systemd 서비스명: ghost-blog.service
이제 왜 web 사용자로 ghost update를 실행했을 때 실패했는지 이해됐습니다.
web 사용자는 /var/www/ghost/.ghost-cli 파일을 수정할 권한이 없었습니다.
이 파일의 소유자는 ghost였고, 권한은 -rw-r--r--였습니다.
즉, 업데이트는 web이 아니라 ghost 사용자로 실행해야 했습니다.
정답은 sudo ghost update가 아니라 sudo -u ghost였습니다
최종적으로 아래 명령어로 업데이트를 실행했습니다.
sudo -u ghost -H bash -lc 'cd /var/www/ghost && ghost update'
이 명령어는 이런 의미입니다.
ghost 사용자 권한으로
홈 환경도 ghost 기준으로 잡고
/var/www/ghost로 이동한 뒤
ghost update를 실행한다
실행 결과는 정상적으로 진행됐습니다.
✔ Checking for latest Ghost version
✔ Checking system Node.js version - found v22.22.2
✔ Checking current folder permissions
✔ Checking memory availability
✔ Checking free space
✔ Checking pnpm installation - found pnpm v10.33.4
✔ Checking for available migrations
# v6.43.1
✔ Fetched release notes
✔ Downloading and updating Ghost to v6.43.1
✔ Linking latest Ghost and recording versions
✔ Linking built-in themes
ℹ Removing old Ghost versions [skipped]
중간에 Ghost-CLI가 오래됐다는 경고는 계속 나왔습니다.
You are running an outdated version of Ghost-CLI.
It is recommended that you upgrade before continuing.
Run `npm install -g ghost-cli@latest` to upgrade.
Node.js 쪽 경고도 하나 있었습니다.
[DEP0040] DeprecationWarning: The `punycode` module is deprecated.
하지만 둘 다 업데이트를 막는 오류는 아니었습니다.
중요한 건 Ghost가 6.43.1까지 올라갔다는 것입니다.
업데이트 후 버전을 다시 확인했습니다
업데이트가 끝났다고 해서 바로 끝내면 안 됩니다.
실제 current가 어떤 버전을 가리키는지 다시 확인했습니다.
cd /var/www/ghost
node -p "require('./current/package.json').version"
readlink -f current
결과는 다음과 같았습니다.
6.43.1
/var/www/ghost/versions/6.43.1
드디어 원하는 상태가 됐습니다.
업데이트 전: Ghost 6.36.0
업데이트 후: Ghost 6.43.1
보안 패치 기준: Ghost 6.37.0 이상
결과: 조치 완료
이 순간이 서버 운영에서 은근히 기분 좋은 순간입니다.
명령어 몇 줄로 끝난 것처럼 보이지만, 실제로는 그 사이에 여러 판단이 들어갑니다.
지금 내가 어떤 사용자로 접속했는지,
서비스는 어떤 사용자로 실행 중인지,
업데이트는 누구 권한으로 해야 하는지,
실제 버전은 올라갔는지.
이런 과정을 남겨두는 게 빌딩 인 퍼블릭의 진짜 기록이라고 생각합니다.
재시작은 해야 할까요?
업데이트 후에는 재시작 여부도 확인했습니다.
Ghost-CLI의 ghost update는 일반적으로 필요한 재시작까지 처리합니다.
그래도 운영 서버라면 상태 확인은 해보는 게 좋습니다.
systemctl status ghost-blog.service
로그도 확인합니다.
journalctl -u ghost-blog.service -n 100 --no-pager
필요하면 직접 재시작할 수도 있습니다.
sudo systemctl restart ghost-blog.service
재시작 후에는 다시 상태와 로그를 확인합니다.
systemctl status ghost-blog.service
journalctl -u ghost-blog.service -n 100 --no-pager
저는 보안 업데이트 후에는 가능하면 한 번 재시작하고 로그까지 보는 편이 좋다고 생각합니다.
안 해도 될 수는 있습니다.
하지만 운영자는 불안하면 확인해야 합니다.
확인하지 않은 정상은 아직 정상인지 아닌지 모릅니다.
업데이트 후 또 하나의 경고: MariaDB
업데이트 후 Ghost 상태를 확인하니 이런 메시지가 나왔습니다.
Version: 6.43.1
Environment: production
Database: mariadb
Mail: SMTP
You are running an unsupported database in production. Please upgrade to MySQL 8.
처음 보면 또 불안합니다.
“업데이트했는데 또 문제인가?”
하지만 이건 이번 보안 업데이트가 실패했다는 의미는 아닙니다.
현재 Ghost가 MariaDB를 사용하고 있는데, Ghost production 환경에서 공식 지원하는 데이터베이스는 MySQL 8이라는 의미입니다.
정리하면 이렇습니다.
Ghost 보안 업데이트: 완료
현재 DB: MariaDB
남은 경고: production에서는 MySQL 8 권장
즉, 지금 당장 블로그가 죽는 문제는 아니지만 운영 환경에서는 언젠가 해결해야 할 숙제입니다.
그리고 MySQL이라고 해서 무조건 유료는 아닙니다.
일반적으로 Ghost 운영에는 무료로 사용할 수 있는 MySQL Community Edition을 쓰면 됩니다.
유료인 것은 MySQL Enterprise Edition입니다.
그래서 이건 다음 작업으로 따로 분리하기로 했습니다.
다음 숙제: MariaDB → MySQL 8 이전
다만 이 작업은 오늘 바로 건드릴 작업은 아닙니다.
DB 이전은 대충 하면 안 됩니다.
반드시 dump를 뜨고, MySQL 8에 import하고, Ghost 설정을 바꾸고, 관리자 로그인과 글 목록, 이미지까지 확인해야 합니다.
대략 이런 순서가 될 것입니다.
1. Ghost content 백업
2. MariaDB DB dump
3. MySQL 8 준비
4. MySQL 8에 DB import
5. Ghost config.production.json DB 연결 변경
6. Ghost 재시작
7. 관리자 로그인 확인
8. 글 목록, 이미지, 테마 확인
9. 문제 없으면 MariaDB 정리
보안 업데이트는 오늘의 일이고, DB 이전은 다음 작업입니다.
운영은 한 번에 다 하려고 하면 오히려 위험합니다.
오늘 끌 불과 내일 고칠 기술부채를 구분해야 합니다.
이번 작업에서 배운 것
이번 Ghost 업데이트는 단순히 ghost update를 친 기록이 아니었습니다.
제가 배운 건 이거였습니다.
서버에서 업데이트를 하기 전에는 먼저 실행 사용자를 확인해야 한다.
이번 goodtek 블로그의 경우는 이랬습니다.
로그인 사용자: web
설치 소유자: ghost
실행 사용자: ghost
서비스명: ghost-blog.service
그래서 정답은 이게 아니었습니다.
sudo ghost update
정답은 이거였습니다.
sudo -u ghost -H bash -lc 'cd /var/www/ghost && ghost update'
이 차이가 중요합니다.
sudo는 강력하지만, 아무 데나 쓰면 서버 권한을 망가뜨릴 수도 있습니다.
특히 Ghost처럼 특정 사용자로 실행되는 서비스는 설치 소유자와 실행 사용자를 맞춰주는 것이 중요합니다.
이번에도 결국 문제는 Ghost가 어려워서가 아니라, 리눅스 권한과 서비스 운영 방식의 문제였습니다.
실제 사용한 명령어 정리
이번 작업에서 사용한 핵심 명령어는 아래와 같습니다.
Ghost-CLI 버전 확인:
ghost --version
Ghost 설치 디렉터리 이동:
cd /var/www/ghost
Ghost 본체 버전 확인:
node current/index.js --version
node -p "require('./current/package.json').version"
현재 버전 링크 확인:
readlink -f current
설치 소유자와 실행 사용자 확인:
whoami
id
ls -ld /var/www/ghost
ls -al /var/www/ghost | head -30
ls -al /var/www/ghost/.ghost-cli
ps -ef | grep '[g]host'
systemctl list-units --type=service | grep ghost
백업:
sudo mkdir -p /home/web/ghost-backups
sudo tar czf /home/web/ghost-backups/ghost_backup_$(date +%Y%m%d_%H%M%S).tar.gz \
-C /var/www \
ghost/content ghost/config.production.json
sudo chown -R web:web /home/web/ghost-backups
ls -lh /home/web/ghost-backups
업데이트:
sudo -u ghost -H bash -lc 'cd /var/www/ghost && ghost update'
업데이트 후 버전 확인:
cd /var/www/ghost
node -p "require('./current/package.json').version"
readlink -f current
서비스 상태 확인:
systemctl status ghost-blog.service
journalctl -u ghost-blog.service -n 100 --no-pager
필요 시 재시작:
sudo systemctl restart ghost-blog.service
마무리
goodtek 블로그는 이제 Ghost 6.43.1로 올라갔습니다.
처음 상태는 이랬습니다.
Ghost 6.36.0
업데이트 후에는 이렇게 바뀌었습니다.
Ghost 6.43.1
이번 보안 공지의 패치 기준은 6.37.0 이상이었기 때문에, 보안 업데이트는 완료됐습니다.
하지만 동시에 다음 숙제도 생겼습니다.
MariaDB에서 MySQL 8로 이전하기
이게 제가 goodtek을 만들면서 계속 기록하려는 이유입니다.
서비스를 만든다는 건 단순히 코드를 짜는 일이 아닙니다.
도메인을 붙이고, 서버를 운영하고, 보안 업데이트를 하고, 권한 문제를 해결하고, 데이터베이스 경고를 만나고, 그걸 하나씩 정리하는 일입니다.
멋진 랜딩페이지보다 이런 기록이 더 현실적인 빌딩 인 퍼블릭일지도 모릅니다.
오늘은 Ghost 보안 업데이트를 끝냈습니다.
내일은 또 다른 경고 하나를 줄이면 됩니다.