PocketBase 入門到實作:從零到上線的完整教學

PocketBase 入門到實作:從零到上線的完整教學

pocketbase-tutorial-cover

前言

上一篇介紹了各種 Supabase 替代品,這篇要深入 PocketBase 的實際操作。從安裝、設定、前端整合到部署上線,完整走一遍流程。

PocketBase 的特點是簡單直接。不需要 Docker,不需要資料庫安裝,下載一個檔案就能跑。這讓它特別適合:

  • 想快速驗證想法的個人開發者
  • 學習後端開發的初學者
  • 需要輕量後端的小型專案
  • 不想維護複雜基礎設施的團隊

這篇教學會涵蓋:

  1. 安裝與啟動
  2. Admin 後台操作
  3. 資料庫設計
  4. 前端整合(含完整程式碼)
  5. 認證系統
  6. 自訂 API
  7. 部署到線上
  8. 實戰案例:待辦清單應用

環境準備

PocketBase 是單一執行檔,不需要預先安裝任何東西。支援的作業系統:

  • Linux(amd64、arm64)
  • macOS(Intel、Apple Silicon)
  • Windows

第一步:安裝與啟動

方法一:直接下載

# macOS (Apple Silicon)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.28.4/pocketbase_0.28.4_darwin_arm64.zip

# macOS (Intel)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.28.4/pocketbase_0.28.4_darwin_amd64.zip

# Linux
wget https://github.com/pocketbase/pocketbase/releases/download/v0.28.4/pocketbase_0.28.4_linux_amd64.zip

# 解壓縮
unzip pocketbase_*.zip

# 啟動
./pocketbase serve

啟動後會看到:

2024/01/01 12:00:00 Server started at http://127.0.0.1:8090
  - REST API: http://127.0.0.1:8090/api/
  - Admin UI: http://127.0.0.1:8090/_/

方法二:Docker

如果偏好容器化部署:

FROM alpine:latest

ARG PB_VERSION=0.28.4

RUN apk add --no-cache unzip ca-certificates

ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/

EXPOSE 8080

CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]
docker build -t pocketbase .
docker run -d -p 8080:8080 -v $(pwd)/pb_data:/pb/pb_data pocketbase

首次設定

開啟 http://127.0.0.1:8090/_/ 會看到管理員帳號設定頁面。填入 email 和密碼後,就能進入 Admin 後台。

這個帳號是超級管理員(Superuser),擁有所有權限。實際專案中請使用強密碼。


第二步:認識 Admin 後台

Admin 後台分為幾個主要區塊:

Collections(資料集合)

類似資料庫的 Table。每個 Collection 包含:

  • 欄位定義:支援多種類型(Text、Number、Bool、Date、File、Relation 等)
  • API 規則:控制 CRUD 權限
  • 索引設定:優化查詢效能

PocketBase 預設有兩個系統 Collection:

  • _superusers:管理員帳號
  • users:一般使用者(Auth Collection)

Logs

查看 API 請求紀錄、錯誤訊息。

Settings

全域設定,包含:

  • 應用程式名稱
  • SMTP 設定(用於發送驗證信)
  • 檔案儲存設定(本地或 S3)
  • 認證選項
  • 備份設定

第三步:建立第一個 Collection

以部落格文章為例,建立 posts Collection:

在 Admin 後台操作

  1. 點擊「New collection」
  2. 輸入名稱:posts
  3. 類型選擇:「Base」(一般資料集合)
  4. 新增欄位:
欄位名稱 類型 設定
title Text Required
content Editor Required
slug Text Required, Unique
status Select Options: draft, published
author Relation 關聯到 users
cover File 允許圖片類型
published_at DateTime -

設定 API 規則

在 Collection 設定的「API Rules」區塊:

// List/Search Rule - 只有已發布的文章對外公開
status = "published"

// View Rule - 同上,或作者本人可看草稿
status = "published" || author = @request.auth.id

// Create Rule - 只有登入使用者可建立
@request.auth.id != ""

// Update Rule - 只有作者可編輯
author = @request.auth.id

// Delete Rule - 只有作者可刪除
author = @request.auth.id

這些規則用類似 SQL WHERE 的語法,@request.auth 代表當前登入的使用者。


第四步:前端整合

安裝 SDK

npm install pocketbase

初始化

import PocketBase from 'pocketbase';

const pb = new PocketBase('http://127.0.0.1:8090');

// 如果在 Node.js 環境,需要處理 localStorage
// pb.authStore = new BaseAuthStore();

CRUD 操作

建立記錄

const record = await pb.collection('posts').create({
    title: '我的第一篇文章',
    content: '<p>這是內容...</p>',
    slug: 'my-first-post',
    status: 'draft',
    author: pb.authStore.record.id
});

console.log(record.id); // 自動生成的 ID

查詢記錄

// 取得列表(分頁)
const resultList = await pb.collection('posts').getList(1, 20, {
    filter: 'status = "published"',
    sort: '-created',
    expand: 'author'
});

console.log(resultList.items);
console.log(resultList.totalItems);
console.log(resultList.totalPages);

// 取得單筆
const record = await pb.collection('posts').getOne('RECORD_ID', {
    expand: 'author'
});

// 用其他欄位查詢
const post = await pb.collection('posts').getFirstListItem(
    `slug = "my-first-post"`
);

更新記錄

const updated = await pb.collection('posts').update('RECORD_ID', {
    title: '更新後的標題',
    status: 'published',
    published_at: new Date().toISOString()
});

刪除記錄

await pb.collection('posts').delete('RECORD_ID');

檔案上傳

// 建立 FormData
const formData = new FormData();
formData.append('title', '帶圖片的文章');
formData.append('content', '<p>內容</p>');
formData.append('slug', 'post-with-image');
formData.append('status', 'draft');
formData.append('author', pb.authStore.record.id);
formData.append('cover', fileInput.files[0]); // File 物件

const record = await pb.collection('posts').create(formData);

// 取得檔案 URL
const coverUrl = pb.files.getUrl(record, record.cover);
// 加上尺寸參數(自動縮放)
const thumbUrl = pb.files.getUrl(record, record.cover, { thumb: '300x200' });

即時訂閱

PocketBase 支援 SSE(Server-Sent Events)實現即時資料同步:

// 訂閱整個 Collection
pb.collection('posts').subscribe('*', function (e) {
    console.log(e.action); // 'create' | 'update' | 'delete'
    console.log(e.record);
});

// 訂閱特定記錄
pb.collection('posts').subscribe('RECORD_ID', function (e) {
    console.log('記錄已更新:', e.record);
});

// 取消訂閱
pb.collection('posts').unsubscribe('*');
pb.collection('posts').unsubscribe('RECORD_ID');
pb.collection('posts').unsubscribe(); // 取消此 Collection 所有訂閱

第五步:認證系統

使用者註冊

const user = await pb.collection('users').create({
    email: 'user@example.com',
    password: '12345678',
    passwordConfirm: '12345678',
    name: '使用者名稱'
});

// 發送驗證信(需要先設定 SMTP)
await pb.collection('users').requestVerification('user@example.com');

登入

// Email + 密碼
const authData = await pb.collection('users').authWithPassword(
    'user@example.com',
    '12345678'
);

console.log(pb.authStore.isValid);  // true
console.log(pb.authStore.token);    // JWT
console.log(pb.authStore.record);   // 使用者資料

OAuth2 登入

需要先在 Admin 後台設定 OAuth2 提供者(Settings → Auth providers)。

// 自動處理彈窗流程
const authData = await pb.collection('users').authWithOAuth2({
    provider: 'google'
});

登出

pb.authStore.clear();

檢查登入狀態

if (pb.authStore.isValid) {
    console.log('已登入:', pb.authStore.record.email);
} else {
    console.log('未登入');
}

// 監聽狀態變化
pb.authStore.onChange((token, record) => {
    console.log('認證狀態改變');
});

刷新 Token

// Token 即將過期時刷新
if (pb.authStore.isValid) {
    await pb.collection('users').authRefresh();
}

第六步:自訂 API 路由

有時候內建的 CRUD API 不夠用,需要自訂邏輯。

使用 JavaScript Hooks

pb_hooks 目錄下建立 .js 檔案:

// pb_hooks/custom_routes.pb.js

// 自訂 GET 路由
routerAdd("GET", "/api/custom/stats", (e) => {
    // 執行 SQL 查詢
    const result = $app.db()
        .newQuery(`
            SELECT
                COUNT(*) as total,
                SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published
            FROM posts
        `)
        .one();

    return e.json(200, result);
}, $apis.requireAuth()); // 需要認證

// 自訂 POST 路由
routerAdd("POST", "/api/custom/publish/{id}", (e) => {
    const id = e.request.pathValue("id");
    const record = $app.findRecordById("posts", id);

    // 檢查權限
    if (record.get("author") !== e.auth.id) {
        throw new ForbiddenError("只有作者可以發布");
    }

    record.set("status", "published");
    record.set("published_at", new Date().toISOString());
    $app.save(record);

    return e.json(200, record);
}, $apis.requireAuth());

Hooks(事件鉤子)

// pb_hooks/hooks.pb.js

// 建立記錄前
onRecordCreate((e) => {
    // 自動設定 slug
    if (!e.record.get("slug")) {
        const title = e.record.get("title");
        const slug = title.toLowerCase().replace(/\s+/g, '-');
        e.record.set("slug", slug);
    }
    return e.next();
}, "posts");

// 建立記錄後
onRecordAfterCreateSuccess((e) => {
    console.log("新文章建立:", e.record.id);
    // 可以在這裡發送通知、更新快取等
}, "posts");

// 刪除記錄前
onRecordDelete((e) => {
    // 阻止刪除已發布的文章
    if (e.record.get("status") === "published") {
        throw new BadRequestError("無法刪除已發布的文章");
    }
    return e.next();
}, "posts");

第七步:部署到線上

方法一:VPS 直接部署

適合:個人專案、小型應用

# 1. 上傳執行檔和 pb_data 到伺服器
scp pocketbase user@server:/opt/pocketbase/
scp -r pb_data user@server:/opt/pocketbase/

# 2. SSH 到伺服器
ssh user@server

# 3. 設定 systemd 服務
sudo nano /etc/systemd/system/pocketbase.service
[Unit]
Description=PocketBase
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve yourdomain.com
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
# 4. 啟動服務
sudo systemctl daemon-reload
sudo systemctl enable pocketbase
sudo systemctl start pocketbase

# 5. 檢查狀態
sudo systemctl status pocketbase

使用 yourdomain.com 作為參數時,PocketBase 會自動處理 Let's Encrypt 憑證。

方法二:Docker + Nginx

適合:需要更多控制、多服務架構

# docker-compose.yml
version: '3.8'

services:
  pocketbase:
    build: .
    container_name: pocketbase
    volumes:
      - ./pb_data:/pb/pb_data
      - ./pb_hooks:/pb/pb_hooks
    restart: unless-stopped
    networks:
      - web

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./certs:/etc/nginx/certs
    depends_on:
      - pocketbase
    networks:
      - web

networks:
  web:

方法三:Railway / Fly.io / Render

這些 PaaS 平台支援 Docker 部署,適合不想管理伺服器的情況。

以 Fly.io 為例:

# fly.toml
app = "my-pocketbase"
primary_region = "nrt"  # 東京

[build]
  dockerfile = "Dockerfile"

[mounts]
  source = "pb_data"
  destination = "/pb/pb_data"

[http_service]
  internal_port = 8080
  force_https = true
fly launch
fly deploy

實戰案例:待辦清單應用

把前面學的整合起來,建立一個完整的待辦清單應用。

資料結構

建立 todos Collection:

欄位 類型 設定
title Text Required
completed Bool Default: false
user Relation 關聯 users, Required
due_date DateTime -

API 規則:

// 所有操作都限制為擁有者
List:   user = @request.auth.id
View:   user = @request.auth.id
Create: @request.auth.id != "" && @request.body.user = @request.auth.id
Update: user = @request.auth.id
Delete: user = @request.auth.id

前端程式碼(Vue 3 範例)

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

const todos = ref([])
const newTodo = ref('')
const loading = ref(false)

// 載入待辦事項
async function loadTodos() {
  loading.value = true
  try {
    const records = await pb.collection('todos').getFullList({
      sort: '-created',
      filter: `user = "${pb.authStore.record.id}"`
    })
    todos.value = records
  } finally {
    loading.value = false
  }
}

// 新增待辦
async function addTodo() {
  if (!newTodo.value.trim()) return

  const record = await pb.collection('todos').create({
    title: newTodo.value,
    completed: false,
    user: pb.authStore.record.id
  })

  todos.value.unshift(record)
  newTodo.value = ''
}

// 切換完成狀態
async function toggleTodo(todo) {
  const updated = await pb.collection('todos').update(todo.id, {
    completed: !todo.completed
  })

  const index = todos.value.findIndex(t => t.id === todo.id)
  todos.value[index] = updated
}

// 刪除待辦
async function deleteTodo(todo) {
  await pb.collection('todos').delete(todo.id)
  todos.value = todos.value.filter(t => t.id !== todo.id)
}

// 即時同步
let unsubscribe

onMounted(async () => {
  await loadTodos()

  // 訂閱變更
  unsubscribe = await pb.collection('todos').subscribe('*', (e) => {
    if (e.action === 'create') {
      // 避免重複新增自己建立的
      if (!todos.value.find(t => t.id === e.record.id)) {
        todos.value.unshift(e.record)
      }
    } else if (e.action === 'update') {
      const index = todos.value.findIndex(t => t.id === e.record.id)
      if (index !== -1) {
        todos.value[index] = e.record
      }
    } else if (e.action === 'delete') {
      todos.value = todos.value.filter(t => t.id !== e.record.id)
    }
  })
})

onUnmounted(() => {
  unsubscribe?.()
})
</script>

<template>
  <div class="todo-app">
    <h1>待辦清單</h1>

    <form @submit.prevent="addTodo" class="add-form">
      <input
        v-model="newTodo"
        placeholder="新增待辦事項..."
        :disabled="loading"
      />
      <button type="submit" :disabled="loading || !newTodo.trim()">
        新增
      </button>
    </form>

    <ul class="todo-list">
      <li
        v-for="todo in todos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        <input
          type="checkbox"
          :checked="todo.completed"
          @change="toggleTodo(todo)"
        />
        <span>{{ todo.title }}</span>
        <button @click="deleteTodo(todo)" class="delete">×</button>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.add-form {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.add-form input {
  flex: 1;
  padding: 10px;
}

.todo-list {
  list-style: none;
  padding: 0;
}

.todo-list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.todo-list li.completed span {
  text-decoration: line-through;
  color: #999;
}

.delete {
  margin-left: auto;
  background: #ff4444;
  color: white;
  border: none;
  padding: 5px 10px;
  cursor: pointer;
}
</style>

常見問題

Q: 資料如何備份?

# 停止服務後複製 pb_data
sudo systemctl stop pocketbase
cp -r /opt/pocketbase/pb_data /backup/pb_data_$(date +%Y%m%d)
sudo systemctl start pocketbase

# 或使用 SQLite 線上備份(不需停止服務)
sqlite3 /opt/pocketbase/pb_data/data.db ".backup /backup/data_$(date +%Y%m%d).db"

Q: 如何重設管理員密碼?

./pocketbase superuser upsert admin@example.com newpassword

Q: 效能瓶頸在哪?

SQLite 的寫入鎖是主要限制。如果需要高併發寫入,考慮:

  • 使用佇列處理寫入請求
  • 評估是否真的需要 PocketBase(可能 PostgreSQL 更適合)

Q: 可以用在生產環境嗎?

可以,但要評估:

  • 預期流量(SQLite 適合中低流量)
  • 資料重要性(確保備份策略)
  • 團隊維護能力

進階學習資源

  • [[PocketBase快速搭建指南]] - 更多部署選項
  • [[PocketBase自訂API路由]] - 深入 API 擴充
  • [[PocketBase認證與權限設定]] - 完整的權限控制
  • 官方文件
  • GitHub 討論區

結語

PocketBase 不是要取代所有後端方案,而是提供一個極簡的選擇。當你的需求是快速驗證想法、建立個人專案、或者只是需要一個簡單的後端時,它能讓你在幾分鐘內就有一個功能完整的後端服務。

這篇教學涵蓋了從入門到實戰的主要知識點。實際專案中可能還會遇到其他問題,但有了這個基礎,查閱官方文件或社群討論應該都能找到解答。

祝開發順利。


本文最初發布於 HackMD @BASHCAT

留言

這個網誌中的熱門文章

Arduino 課本可能沒教的事(1)

SI4432 搭配Arduino

燒錄 Arduino mini Pro 燒錄