Hướng dẫn triển khai Ghost CMS production trên VPS Ubuntu đã cài aaPanel và Docker. Trong mô hình này, Docker/Compose quản lý Ghost và dữ liệu persistent, còn Nginx của aaPanel đảm nhiệm domain, HTTPS và reverse proxy. Bài cũng hướng dẫn kiểm tra MySQL network, nghiệm thu từng bước và xử lý các lỗi thường gặp nhưRequest made from incorrect origin,ECONNREFUSED ...:443vàIMAGE_SIZE_URL.
Quick Start — 7 bước để triển khai một website Ghost
Nếu bạn đã quen Docker và aaPanel, có thể đi nhanh theo quy trình này:
- Chuẩn bị domain, VPS, MySQL 8 và SMTP.
- Tạo Ghost bằng Docker Compose, mount
/var/lib/ghost/content. - Bind host port vào
127.0.0.1, ví dụ2369:2368. - Kiểm tra
docker compose config,docker compose psvàcurl http://127.0.0.1:2369/. - Tạo website/domain trong aaPanel và Reverse Proxy tới
http://127.0.0.1:2369. - Kiểm tra Nginx, cấp SSL Let's Encrypt và mở
https://domain.com/ghost/. - Nếu có lỗi, kiểm tra theo thứ tự: Docker/DB → host port → aaPanel Proxy → SSL → DNS bên trong container.
Nguyên tắc quan trọng: Nếu curl http://127.0.0.1:<HOST_PORT>/ chưa trả response, chưa nên sửa Nginx.1. Bài này dành cho mô hình nào?
Bài này dành cho trường hợp VPS Ubuntu đã cài aaPanel, Docker được quản lý từ aaPanel, còn website public được phục vụ bởi Nginx của aaPanel. Ghost chạy bên trong container và chỉ mở một host port nội bộ để Nginx chuyển tiếp request vào.
Đây là mô hình khác với bộ công cụ Ghost Docker chính thức, trong đó Ghost có thể chạy cùng Caddy, MySQL và các service bổ sung trong một stack riêng. Không nên chạy đồng thời Caddy của stack Ghost và Nginx aaPanel để cùng giữ một domain, vì cả hai sẽ tranh nhau cổng 80/443 và làm quy trình cấp SSL, log, proxy khó kiểm tra.
Kiến trúc tổng thể
INTERNET
│
▼
domain.com:443
│
▼
aaPanel / Nginx
│
▼
127.0.0.1:<HOST_PORT>
│
▼
Ghost :2368
│
▼
MySQL :3306Ví dụ một website mới:
Domain: domain.com
Host port: 2369
Ghost: 2368
Proxy: http://127.0.0.1:2369
MySQL: mysql_M8aG:3306
Database: your_dbĐiểm dễ nhầm:2369là port trên VPS;2368là port Ghost bên trong container.
2. Hiểu đúng port, URL, MySQL host và Docker network
2.1. Mapping port
Nếu Compose có:
ports:
- "127.0.0.1:2369:2368"thì:
VPS:
127.0.0.1:2369
↓ Docker port mapping
Container:
0.0.0.0:2368Vì vậy Nginx aaPanel phải proxy tới:
http://127.0.0.1:2369Không proxy tới 2368 nếu host port thực tế là 2369.
Không proxy tới IP container như 172.18.0.3, vì IP container có thể thay đổi sau khi recreate.
2.2. Public URL của Ghost
Ghost production cần public URL. Nếu website dùng SSL, URL phải là:
https://domain.comVí dụ:
environment:
url: https://domain.comKhông dùng:
http://localhost:2368cho website production.
Các tùy chọn cấu hình lồng nhau của Ghost có thể truyền qua environment bằng hai dấu gạch dưới, ví dụ:
database__connection__host
database__connection__user
database__connection__password
database__connection__database2.3. MySQL host trong Docker
Nếu Ghost và MySQL ở cùng Docker network, Ghost phải kết nối tới hostname có thể được Docker DNS phân giải, thường là service name/container name trong network phù hợp.
Ví dụ:
mysql_M8aE:3306Không mặc định dùng:
127.0.0.1:3306bên trong Ghost container.
127.0.0.1 trong container là chính container Ghost, không phải MySQL container.
3. Chuẩn bị VPS, domain, MySQL và SMTP
3.1. VPS
VPS nên chạy Ubuntu LTS và có quyền root hoặc tài khoản sudo.
Bạn cần:
- Ubuntu LTS
- aaPanel
- Docker/Compose
- DNS trỏ về VPS
- MySQL 8
- SMTP cho email giao dịch của Ghost
Ghost production cũng cần HTTPS và reverse proxy với forwarded headers phù hợp.
3.2. Domain
Domain cần có bản ghi A trỏ tới IPv4 của VPS.
Ví dụ:
Type: A
Name: @
Value: IP_VPSNếu dùng www:
Type: A
Name: www
Value: IP_VPSNếu domain có bản ghi AAAA, hãy bảo đảm IPv6 cũng trỏ đúng VPS. Nếu chưa sử dụng IPv6, có thể tạm thời gỡ AAAA để tránh một số client đi tới máy chủ khác.
Kiểm tra:
dig +short domain.com3.3. Firewall
Mở tối thiểu:
TCP 80
TCP 443Host port Ghost không cần public ra Internet nếu Nginx aaPanel và Ghost nằm trên cùng VPS.
Nên bind:
ports:
- "127.0.0.1:2369:2368"thay vì mở:
ports:
- "2369:2368"3.4. MySQL
Ghost production cần MySQL 8.
Bạn cần biết rõ:
MYSQL_HOST
MYSQL_PORT
MYSQL_DATABASE
MYSQL_USER
MYSQL_PASSWORDVí dụ:
MYSQL_HOST=mysql_M8aG
MYSQL_PORT=3306
MYSQL_DATABASE=your_db
MYSQL_USER=user_db
MYSQL_PASSWORD=<PASSWORD>Nếu MySQL đã được tạo sẵn trong một Compose project khác, cần xác định Docker network mà MySQL đang tham gia trước khi tạo Ghost.
4. Xác định Docker network của MySQL trước khi tạo Ghost
Đây là bước rất quan trọng khi MySQL không nằm cùng Compose project với Ghost.
Ví dụ MySQL container có tên:
mysql_M8aGKiểm tra:
docker inspect mysql_M8aE --format '{{json .NetworkSettings.Networks}}'Bạn sẽ thấy một hoặc nhiều network.
Ví dụ:
baota_netTiếp theo:
docker network inspect baota_netTìm mysql_M8aE trong danh sách Containers.
Nếu MySQL nằm trong network baota_net, Ghost project có thể tham gia cùng external network đó:
networks:
mysql_network:
external: true
name: baota_netKhông copy baota_net một cách máy móc. Hãy dùng đúng network thực tế của MySQL trên VPS.Checkpoint
Sau bước này bạn phải trả lời được:
MySQL container: mysql_M8aG
MySQL port: 3306
Docker network: <network-thực-tế>
Database: your_db
User: user_dbNếu chưa xác định được network, chưa nên chạy Ghost.
5. Tạo Ghost bằng Docker Compose trong aaPanel
Mở aaPanel và vào khu vực:
Docker → ComposeTên menu có thể thay đổi theo phiên bản aaPanel. Mục tiêu là tạo một Compose project có volume persistent, không phải một container tạm thời không có dữ liệu.
5.1. Đường dẫn project không cố định
aaPanel có thể lưu Compose project trong thư mục do panel quản lý, ví dụ:
/www/dk_project/dk_app/ghost/<domain>/Đây chỉ là ví dụ.
Hãy lấy đường dẫn thật từ:
- Enter installation directory
- Compose project
- Nút mở thư mục của aaPanel
Sau đó SSH:
cd <AA_PANEL_COMPOSE_DIR>6. Compose mẫu cho một website Ghost
File thực tế do aaPanel sinh có thể có các biến khác nhau. Ví dụ dưới đây là mẫu để hiểu cấu trúc:
services:
ghost:
image: ghost:6-alpine
restart: unless-stopped
ports:
- "127.0.0.1:${HOST_PORT}:2368"
environment:
NODE_ENV: production
url: https://${DOMAIN}
database__client: mysql
database__connection__host: ${MYSQL_HOST}
database__connection__port: ${MYSQL_PORT}
database__connection__user: ${MYSQL_USER}
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ${MYSQL_DATABASE}
volumes:
- ./content:/var/lib/ghost/content
networks:
- mysql_network
networks:
mysql_network:
external: true
name: <MYSQL_DOCKER_NETWORK>Nếu file hiện tại của aaPanel đang dùng:
WEB_HTTP_PORT
MYSQL_HOST_IP
MYSQL_DATABASE
HOST_IPthì giữ nguyên tên biến của file đó. Không đổi tên biến chỉ vì snippet minh họa dùng tên khác.
7. Ví dụ hoàn chỉnh: triển khai website thứ hai domain.com
Giả sử:
Domain: domain.com
Host port: 2369
Ghost port: 2368
MySQL host: mysql_aq
MySQL port: 3306
Database: name_db
User: user_db
Password: <PASSWORD>Compose có thể có dạng:
services:
ghost:
image: ghost:6-alpine
restart: unless-stopped
ports:
- "127.0.0.1:2369:2368"
environment:
NODE_ENV: production
url: https://domain.com
database__client: mysql
database__connection__host: mysql_aq
database__connection__port: 3306
database__connection__user: user_db
database__connection__password: <PASSWORD>
database__connection__database: name_db
volumes:
- ./content:/var/lib/ghost/content
networks:
- mysql_network
networks:
mysql_network:
external: true
name: <MYSQL_DOCKER_NETWORK>Password DB thật không nên đưa trực tiếp vào bài viết hoặc commit vào Git public.
8. Volume /var/lib/ghost/content là bắt buộc phải hiểu
Ghost lưu phần content quan trọng trong:
/var/lib/ghost/contentVí dụ:
volumes:
- ./content:/var/lib/ghost/contentPhần này bảo vệ những dữ liệu như:
- ảnh
- theme
- content được lưu trong content directory
Database vẫn cần backup riêng.
Backup volume không thay thế database dump.
9. Kiểm tra Compose trước khi chạy
Sau khi tạo/chỉnh project:
cd <AA_PANEL_COMPOSE_DIR>
docker compose config
docker compose ps
docker compose logs --tail=100 <ghost_service>docker compose config
Giúp phát hiện:
- YAML sai
- biến environment bị rỗng
- cấu hình Compose không hợp lệ
docker compose ps
Cho biết:
- service nào đang chạy
- container nào đang chạy
- port nào đã map
- trạng thái container
Kiểm tra port
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'Bạn cần thấy đại loại:
127.0.0.1:2369->2368/tcp10. Khởi động Ghost
Nếu Compose đã đúng:
docker compose up -dKiểm tra:
docker compose psXem log:
docker compose logs --tail=200 <ghost_service>Checkpoint
Ghost cần:
- container đang
Up - không có lỗi MySQL
- port host đúng
- content đã mount
11. Test Ghost ngay trên VPS trước khi cấu hình domain
Đây là checkpoint quan trọng nhất.
Ví dụ host port là 2369:
curl -I http://127.0.0.1:2369/Nếu Ghost phản hồi, hãy tiếp tục sang aaPanel.
Nếu không phản hồi:
docker compose logs --tail=200 <ghost_service>Nếu curl thất bại
Đừng sửa Nginx ngay.
Kiểm tra theo thứ tự:
1. Container có chạy không?
2. Port có map đúng không?
3. Ghost có start thành công không?
4. Ghost có kết nối MySQL không?
5. Host port có listen không?Checkpoint
✅ curl http://127.0.0.1:2369/ có response
→ Sang bước aaPanel Proxy
❌ Connection refused / timeout
→ Sửa Docker/Ghost trước12. Kiểm tra public URL bên trong Ghost
Dùng service name thực tế:
docker compose exec <ghost_service> env | grep -E '^(url|NODE_ENV)='Mong muốn:
url=https://domain.com
NODE_ENV=productionKhông sửa config.production.json trong container chỉ vì file mặc định còn hiển thị localhost.
Environment variables có ưu tiên cao hơn file config. Cấu hình persistent nên đặt trong Compose/aaPanel để không mất sau recreate.
13. Tạo website trong aaPanel
Trong aaPanel:
WebsiteTạo website cho:
domain.comHoặc dùng:
Website → Proxytùy phiên bản aaPanel.
14. Tạo Reverse Proxy
Target:
http://127.0.0.1:2369Rất quan trọng
Đúng:
http://127.0.0.1:2369Sai:
http://127.0.0.1:2368vì 2368 là port bên trong container.
Cũng không nên dùng:
http://172.18.0.3vì IP container có thể thay đổi.
Không dùng:
http://ghost:2368trong Nginx host, vì tên service chỉ được Docker DNS phân giải bên trong Docker network.
15. Header reverse proxy
Nếu aaPanel đã tự tạo proxy và header cần thiết, không tạo thêm location / thứ hai.
Trong config hiện có, kiểm tra các header tương đương:
location / {
proxy_pass http://127.0.0.1:<HOST_PORT>;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}Host và X-Forwarded-Host phải giữ hostname public.
X-Forwarded-Proto phải cho Ghost biết request ban đầu là HTTPS.
Sau khi chỉnh:
sudo nginx -t
sudo systemctl reload nginxCheckpoint
✅ nginx -t: syntax is ok
✅ nginx reload thành công
→ Sang SSL
❌ nginx -t lỗi
→ Sửa config Nginx trước16. Cấp SSL Let's Encrypt
Trong aaPanel:
Website → domain.com → SSLChọn:
Let's EncryptCấp certificate cho domain.
DNS phải trỏ đúng VPS.
Sau đó kiểm tra:
curl -I https://domain.com/
curl -I https://domain.com/ghost/Mở trình duyệt:
https://domain.com/ghost/17. Nếu Ghost redirect về localhost hoặc domain cũ
Kiểm tra:
docker compose exec <ghost_service> env | grep -E '^(url|NODE_ENV)='Mong muốn:
url=https://domain.com
NODE_ENV=productionKhông dùng:
http://localhost:2368cho production.
18. Sửa lỗi Request made from incorrect origin
Lỗi này thường liên quan đến sự không nhất quán giữa:
- public URL của Ghost
HostX-Forwarded-HostX-Forwarded-Proto
Kiểm tra:
cd <AA_PANEL_COMPOSE_DIR>
docker compose config
docker compose exec <ghost_service> env | grep -E '^(url|NODE_ENV)='
sudo nginx -tKết quả:
url=https://domain.com
NODE_ENV=productionSau đó kiểm tra proxy config.
Nếu header bị ghi đè thành:
localhost
IP container
httphãy sửa lại.
Sau đó:
sudo nginx -t
sudo systemctl reload nginxThử:
https://domain.com/ghost/và hard refresh/private window.
Nếu/ghost/mở bình thường, không cần sửa file config nội bộ trong container chỉ để đổilocalhost.
19. Sửa lỗi ECONNREFUSED ...:443 và IMAGE_SIZE_URL
Đây là nhánh lỗi khác.
Website có thể:
- truy cập bình thường
/ghost/hoạt động
nhưng Ghost lại tự gọi public domain từ bên trong container.
Ví dụ log:
ECONNREFUSED 172.18.0.3:443
IMAGE_SIZE_URLĐiều đó có thể xảy ra khi DNS trong container trỏ domain về chính IP Ghost container, trong khi HTTPS thực tế do Nginx trên host lắng nghe.
19.1. Kiểm tra DNS từ bên trong Ghost
docker compose exec <ghost_service> getent hosts mikka.io.vnLấy IP Ghost container:
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <ghost_container>Chỉ kết luận là hairpin DNS khi:
IP DNS trả về == IP Ghost containervà log cho thấy kết nối tới:
<ghost-container-ip>:44320. Dùng host-gateway khi đúng điều kiện
Trong service Ghost:
extra_hosts:
- "mikka.io.vn:host-gateway"Hoặc:
extra_hosts:
- "${DOMAIN}:host-gateway"Sau đó:
docker compose config
docker compose up -d --force-recreateKiểm tra:
docker compose exec <ghost_service> getent hosts mikka.io.vnXem log:
docker compose logs --tail=200 <ghost_service> | grep -Ei 'ECONNREFUSED|IMAGE_SIZE_URL|ERROR'host-gateway map hostname tới gateway của Docker host. Đây là mapping trong container, không thay thế DNS public.
Không copy cứng 172.17.0.1. Gateway thực tế có thể khác theo network.Nếu vẫn lỗi, kiểm tra:
- host port Ghost
- firewall
- Nginx
- khả năng hairpin routing
21. ActivityPub và webhook là lỗi riêng
Bạn có thể gặp:
Could not get webhook secret for ActivityPub
SyntaxError: Unexpected token '<', "<!DOCTYPE ..." is not valid JSONĐiều đó có nghĩa một request đang mong JSON nhưng nhận HTML.
Nếu:
https://domain.com/
https://domain.com/ghost/vẫn hoạt động thì không nên kết luận ngay reverse proxy bị hỏng.
Nếu bạn không dùng ActivityPub self-hosted, hãy xử lý theo đúng tính năng và phiên bản Ghost đang chạy.
Không chữa lỗi này bằng cách đổi ngẫu nhiên:
proxy_pass
headers
Docker IP22. Chạy nhiều Ghost trên cùng một VPS
Đây là một trong những lợi ích lớn của mô hình host-port riêng.
Ví dụ:
Ghost site 1
domain1.com
127.0.0.1:2369 → Ghost :2368
Ghost site 2
domain2.com
127.0.0.1:2370 → Ghost :2368
Ghost site 3
domain3.com
127.0.0.1:2371 → Ghost :2368Nginx:
domain1.com → 127.0.0.1:2369
domain2.com → 127.0.0.1:2370
domain3.com → 127.0.0.1:2371Mỗi site nên có:
- Compose project riêng
- content volume riêng
- database riêng
- credential riêng
- host port riêng
- domain riêng
Sơ đồ:
Nginx :443
│
┌────────────────┼────────────────┐
▼ ▼ ▼
:2369 :2370 :2371
│ │ │
Ghost 1 Ghost 2 Ghost 3
│ │ │
DB1 DB2 DB3Không cần dùng các port Ghost khác nhau bên trong container. Mỗi container Ghost vẫn có thể listen 2368; chỉ cần host port khác nhau.23. Backup trước rebuild, uninstall và nâng cấp
Ghost có hai nhóm dữ liệu chính cần bảo vệ:
1. /var/lib/ghost/content
2. MySQL databaseTrước khi:
- Rebuild
- Uninstall
- đổi database credential
- đổi volume
- nâng version
hãy backup trước.
Một lỗi rất dễ mắc
Đổi:
MYSQL_PASSWORD=...trong .env
không tự đổi password thật của user trong MySQL.
Nếu password trong Compose và password thật trong MySQL không đồng bộ, Ghost sẽ mất kết nối database.
24. Nâng Ghost an toàn
Nên:
- Backup content.
- Backup database.
- Đọc release notes.
- Pin version đã kiểm thử.
- Nâng từng minor version có kiểm soát.
- Kiểm tra log sau nâng cấp.
Ví dụ:
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 <ghost_service>Không nên sử dụng latest một cách mù quáng cho production nếu chưa kiểm thử.
Không dùng docker compose down -v nếu chưa hiểu rõ volume và dữ liệu nào sẽ bị xóa.25. Checklist nghiệm thu production
[ ] DNS A/AAAA trỏ đúng VPS
[ ] TCP 80/443 mở
[ ] Docker Compose project chạy trong aaPanel
[ ] Ghost content được mount persistent
[ ] MySQL 8 và credential hoạt động
[ ] Ghost tham gia đúng Docker network của MySQL
[ ] Host port Ghost chỉ bind 127.0.0.1
[ ] docker compose config không còn biến rỗng
[ ] docker compose ps cho thấy Ghost đang chạy
[ ] curl http://127.0.0.1:<HOST_PORT>/ trả response
[ ] aaPanel Proxy target đúng 127.0.0.1:<HOST_PORT>
[ ] Host/X-Forwarded-Host/X-Forwarded-Proto đúng
[ ] nginx -t thành công
[ ] SSL Let's Encrypt hoạt động
[ ] https://domain.com/ hoạt động
[ ] https://domain.com/ghost/ hoạt động
[ ] Ghost url dùng https://domain.com
[ ] Chỉ thêm extra_hosts khi getent hosts xác nhận hairpin DNS
[ ] Có backup content
[ ] Có backup database
[ ] SMTP được cấu hình trước khi dùng member/newsletter26. Troubleshooting nhanh
| Lỗi | Nguyên nhân thường gặp | Kiểm tra trước |
|---|---|---|
Connection refused ở 127.0.0.1:<HOST_PORT> | Ghost/container chưa chạy hoặc port mapping sai | docker compose ps |
502 Bad Gateway | Nginx proxy sai host port hoặc Ghost chưa listen | curl 127.0.0.1:<HOST_PORT> |
Request made from incorrect origin | url hoặc forwarded headers sai | url, Host, X-Forwarded-* |
ECONNREFUSED ...:443 | Ghost tự gọi domain nhưng DNS trỏ vào container | getent hosts domain.com |
IMAGE_SIZE_URL | Ghost không truy cập được public URL theo expected route | DNS từ bên trong container |
Redirect về localhost | url production sai | docker compose exec ... env |
| DB access denied | credential không khớp MySQL thật | kiểm tra user/password/database |
Unknown database | tên DB sai hoặc chưa tạo | kiểm tra MYSQL_DATABASE |
| ActivityPub trả HTML thay vì JSON | webhook/endpoint trả response không đúng | kiểm tra ActivityPub/webhook riêng |
| Mất ảnh/theme sau recreate | volume content chưa persistent | kiểm tra /var/lib/ghost/content |
27. Câu hỏi thường gặp
Có bắt buộc cài Ghost vào /opt/ghost-aapanel không?
Không.
Đó chỉ là một đường dẫn minh họa cho Docker Compose thuần.
Khi dùng aaPanel, hãy dùng đường dẫn mà aaPanel hiển thị.
Có nên mở host port Ghost ra Internet không?
Không cần trong mô hình aaPanel Nginx cùng VPS.
Nên:
127.0.0.1:<HOST_PORT>Sau đó public website qua:
443Có phải lúc nào cũng cần extra_hosts không?
Không.
Chỉ thêm khi:
- domain public của Ghost bị resolve về chính IP container
- log cho thấy Ghost tự kết nối tới
container-ip:443
Nếu DNS từ container bình thường thì không thêm workaround.
Có được dùng IP container cố định không?
Không nên.
Docker cấp IP container động; sau recreate IP có thể thay đổi.
Trong cùng Compose network:
service name → containerĐể container gọi host:
host-gatewaykhi đúng trường hợp.
Có nên chạy Ghost official Caddy stack trên aaPanel không?
Có thể, nhưng đó là kiến trúc khác.
Không nên để:
Caddy
+
Nginx aaPanelcùng giữ domain/cổng 80/443.
Hãy chọn một lớp reverse proxy/TLS chính cho domain đó.
28. Kết luận
Cài Ghost production trên Ubuntu có aaPanel Docker không chỉ là chạy một image Ghost.
Cần tách đúng trách nhiệm:
aaPanel Docker/Compose
→ quản lý container và dữ liệu persistent
aaPanel Nginx
→ domain + SSL + reverse proxy
Ghost
→ public URL HTTPS + application
MySQL
→ databaseKhi triển khai, hãy kiểm tra từ trong ra ngoài:
Docker + DB
↓
Host port
↓
aaPanel Proxy
↓
Nginx + SSL
↓
Public domainVới lỗi:
ECONNREFUSED
IMAGE_SIZE_URLhãy kiểm tra DNS bên trong container trước khi thêm extra_hosts.
Với lỗi:
Request made from incorrect originhãy kiểm tra url của Ghost và các forwarded headers trước.
Đây là cách giúp mô hình aaPanel + Docker không chỉ chạy được một website Ghost, mà còn có thể mở rộng thành nhiều Ghost/domain trên cùng một VPS với mỗi site có:
Compose riêng
Port riêng
Database riêng
Content riêng
Domain riêng