Deploy Next.js preview qua Cloudflare Tunnel trên VPS 2GB không cần Vercel

Chia sẻ bài viết

Mục lục
TL;DR
  • Cloudflare Tunnel expose VPS ra public HTTPS không cần mở port firewall.
  • Mỗi PR GitHub tự deploy preview riêng: pr-123.preview.app.com, giống Vercel.
  • Auto SSL Cloudflare miễn phí, không cần Let Encrypt setup.
  • VPS 2GB chạy được 5-10 preview song song nhờ PM2 cluster theo PR number.
  • Tổng chi phí: 199k VPS + miễn phí Cloudflare = 199k/tháng cho mọi PR preview.

Vercel free tier 100GB bandwidth/tháng nghe ngon, nhưng project bạn vừa nhận traffic là hết quota, charge bất ngờ. Hoặc bạn không muốn deploy code sensitive lên cloud Mỹ vì lý do compliance. Self-host Next.js trên Cloud VPS 2GB + Cloudflare Tunnel là giải pháp pro: public HTTPS, PR preview, không charge bất ngờ.

Cloudflare Tunnel (cloudflared) là tool free tạo tunnel ngược từ VPS lên Cloudflare edge. VPS không cần mở port 80/443, không cần public IP, vẫn được expose ra Internet với SSL Cloudflare. Hoàn hảo cho VPS sau NAT, lab, hoặc dev preview.

Bài này hướng dẫn full pipeline: cài cloudflared trên VPS, setup tunnel cho main app, tạo PR preview tự động qua GitHub Actions với subdomain riêng pr-123.preview.app.com. Test trên Cloud VPS TND 2GB Ubuntu 24.04.

1. Cài Cloudflare Tunnel trên VPS

# Ubuntu/Debian
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb

# Verify
cloudflared --version

# Login Cloudflare
cloudflared tunnel login
# Mở browser, chọn domain quản lý, authorize

Sau khi login, cert file lưu ở ~/.cloudflared/cert.pem. Tạo tunnel:

cloudflared tunnel create my-app-tunnel
# Output: Created tunnel my-app-tunnel with id xxxxxxxx-xxxx
# Credentials written to /root/.cloudflared/xxxxxxxx-xxxx.json

cloudflared tunnel list

2. Config tunnel route DNS

# Tạo file ~/.cloudflared/config.yml
tunnel: xxxxxxxx-xxxx
credentials-file: /root/.cloudflared/xxxxxxxx-xxxx.json

ingress:
  - hostname: app.com
    service: http://localhost:3000
  - hostname: preview.app.com
    service: http://localhost:4000
  - hostname: "*.preview.app.com"
    service: http://localhost:4000
  - service: http_status:404

# Route DNS qua Cloudflare
cloudflared tunnel route dns my-app-tunnel app.com
cloudflared tunnel route dns my-app-tunnel preview.app.com
cloudflared tunnel route dns my-app-tunnel "*.preview.app.com"

Wildcard *.preview.app.com cho phép mọi subdomain dạng pr-123.preview.app.com tự route về VPS. Đây là trick chính cho PR preview.

3. Chạy tunnel như systemd service

sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared

# Logs
sudo journalctl -u cloudflared -f

Tunnel chạy nền 24/7, auto reconnect khi mạng đứt. RAM ngốn khoảng 30-50MB, không đáng kể.

4. Build Next.js production và chạy main app

# next.config.js
module.exports = {
  output: 'standalone',
}

cd /var/app/main
npm ci
npm run build
node .next/standalone/server.js
# Listen on port 3000

# Hoặc với PM2
pm2 start .next/standalone/server.js --name main-app -- -p 3000
pm2 save

Truy cập https://app.com - Cloudflare Tunnel forward request về localhost:3000 trên VPS. SSL handled by Cloudflare edge, không cần Let Encrypt.

5. Router cho PR preview - Caddy reverse proxy

Mỗi PR có 1 port riêng (4001, 4002, ...), Caddy ở port 4000 route request theo subdomain:

# /etc/caddy/Caddyfile
:4000 {
    @pr {
        host_regexp pr_id ^pr-(d+).preview.app.com$
    }
    handle @pr {
        # Extract PR number, route tới port 4000 + pr_number
        reverse_proxy 127.0.0.1:{re.pr_id.1}
    }
    handle {
        respond "Not found" 404
    }
}

sudo systemctl reload caddy

Caddy parse subdomain, extract PR number, forward tới port tương ứng. PR 123 -> port 4123, PR 456 -> port 4456.

6. GitHub Actions deploy PR preview tự động

# .github/workflows/preview.yml
name: PR Preview
on:
  pull_request:
    types: [opened, synchronize, reopened]
  push:
    branches: [main]

jobs:
  deploy-preview:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.SSH_KEY }}
          script: |
            PR_NUM=${{ github.event.pull_request.number }}
            PORT=$((4000 + PR_NUM))
            DIR=/var/preview/pr-$PR_NUM

            mkdir -p $DIR
            cd $DIR

            git clone --depth 1 --branch ${{ github.head_ref }} 
              https://github.com/${{ github.repository }} . || git pull
            git checkout ${{ github.event.pull_request.head.sha }}

            npm ci --no-audit
            npm run build

            pm2 delete preview-$PR_NUM || true
            PORT=$PORT pm2 start .next/standalone/server.js 
              --name preview-$PR_NUM --max-memory-restart 300M

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `Preview: https://pr-${context.issue.number}.preview.app.com`
            })

7. Cleanup preview khi PR đóng

# .github/workflows/cleanup-preview.yml
name: Cleanup Preview
on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Stop preview
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.SSH_KEY }}
          script: |
            PR_NUM=${{ github.event.pull_request.number }}
            pm2 delete preview-$PR_NUM || true
            rm -rf /var/preview/pr-$PR_NUM

8. Tối ưu RAM cho nhiều preview song song

VPS 2GB chạy được khoảng 5-8 PR preview song song nếu mỗi preview giới hạn 200-300MB. Set max_memory_restart trong PM2:

pm2 start server.js 
  --name preview-$PR_NUM 
  --max-memory-restart 300M 
  --node-args="--max-old-space-size=256"

Nếu cần chạy 15+ preview, upgrade VPS lên 4GB (399k/tháng) hoặc tắt preview cũ không dùng tay.

9. So sánh với Vercel

FeatureVercelVPS + Cloudflare Tunnel
Preview URL tự độngCó (mặc định)Có (setup 1 lần)
SSL HTTPSCó (Cloudflare edge)
Cold start~100ms (serverless)0ms (PM2 always-on)
Bandwidth quota100GB/tháng freeKhông giới hạn
Charge bất ngờCó khi vượt quotaKhông
Custom runtimeHạn chếToàn quyền VPS
Edge cachingTự độngCấu hình Cloudflare rules
Chi phí/tháng0-20 USD199k VND VPS

10. Bảo vệ preview - không cho public truy cập

Preview chỉ team xem được, đừng để public crawler. Dùng Cloudflare Access policy hoặc basic auth:

# Caddy basic auth
*.preview.app.com {
    basicauth {
        team JDJhJDE0J... # bcrypt hash của password
    }
    reverse_proxy ...
}

# Hoặc Cloudflare Access (free 50 user):
# Cloudflare dashboard -> Access -> Applications
# Add preview.app.com với policy email = @company.com

11. Monitoring và logs

# Xem trạng thái tất cả preview
pm2 list
pm2 logs preview-123 --lines 50
pm2 monit

# Cloudflare tunnel logs
sudo journalctl -u cloudflared -f --since "10 min ago"

# Cleanup stale preview (chạy daily cron)
cat > /usr/local/bin/cleanup-stale.sh <<'SH'
#!/bin/bash
pm2 jlist | jq -r '.[] | select(.pm2_env.created_at < (now - 86400*7)*1000) | .name' | xargs -I{} pm2 delete {}
SH
chmod +x /usr/local/bin/cleanup-stale.sh
echo '0 3 * * * /usr/local/bin/cleanup-stale.sh' >> /etc/crontab

12. Troubleshooting common issues

  • 502 Bad Gateway: tunnel up nhưng app local crash. Check pm2 logs preview-XXX.
  • SSL cert pending: wildcard DNS chưa propagate, đợi 5-10 phút sau khi tạo route.
  • OOM kill: quá nhiều preview, giảm max-memory-restart hoặc upgrade VPS.
  • Port conflict: 2 PR cùng số -> đảm bảo cleanup workflow chạy đúng.
  • Slow build: dùng npm ci thay npm install, cache .next/cache giữa lần build.
Cloud VPS cho vibe coder

Self-host Next.js + PR preview chỉ với VPS 199k/tháng

Cloud VPS TND sẵn AlmaLinux 9, Ubuntu 22/24, Debian 12/13. SSD CEPH, snapshot 1-click, backup hằng ngày, network 200Mbps trong nước. Cài cloudflared trong 5 phút, deploy PR preview không lo Vercel charge bất ngờ.

Xem 8 cấu hình Cloud VPS →

FAQ

Cloudflare Tunnel có thật sự free không?

Có. Cloudflare Tunnel (cloudflared) miễn phí hoàn toàn cho mọi domain bạn quản lý trên Cloudflare. Không giới hạn bandwidth, không giới hạn tunnel.

Tunnel có chậm hơn deploy thông thường không?

Có thêm ~20-50ms latency vì traffic qua Cloudflare edge. Bù lại được CDN cache, DDoS protection, SSL free. Cho preview site thì không vấn đề gì.

VPS 2GB chạy được tối đa bao nhiêu PR preview?

Khoảng 5-8 preview nếu giới hạn mỗi preview 200-300MB RAM. Trừ Postgres + Redis nếu có. Cần nhiều hơn thì upgrade lên 4GB chạy 12-15 preview.

Có nên dùng ngrok thay Cloudflare Tunnel?

Ngrok free chỉ cho 1 tunnel, URL random thay đổi mỗi lần restart. Cloudflare Tunnel free không giới hạn, custom domain, ổn định 24/7. Cloudflare thắng cho production use case.

Build Next.js trên CI hay trên VPS tốt hơn?

Build trên VPS đỡ phải transfer 100MB .next folder. Nhưng nếu VPS yếu (1 vCPU) thì build chậm, blocking. Compromise: build trên CI, transfer dist .next qua scp.

Bảo mật preview thế nào để Google không index?

Thêm X-Robots-Tag: noindex header trong response, hoặc dùng Cloudflare Access policy yêu cầu login email. Cách dứt khoát: basic auth qua Caddy, password chia sẻ qua 1password team.

2009
15+ năm vận hành liên tục
10+
tập đoàn lớn tin dùng
100+
doanh nghiệp SMB Việt
30 ngày
đổi key lỗi miễn phí
Phần mềm bản quyền chính hãng chúng tôi cung cấp
Bản quyền chính hãng Hóa đơn VAT đầy đủ Đổi key lỗi 30 ngày Vận hành từ 2009 MST 0200994870 Hotline 0225.999.6666