SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-21

Tự host Deer Workflow trên VPS bằng systemd

Cài Deer Workflow trên Ubuntu VPS với Bun, pin version phát hành lần đầu tháng 7/2026 và chạy agent graph TypeScript headless bằng systemd.

Bạn sẽ xây dựng gì

Deer Workflow là runtime theo hướng code-first cho các agent graph: control flow nằm trong một file TypeScript để bạn review, còn coding agent chỉ thực hiện những phần cần phán đoán. Hướng dẫn này cài đặt nó trên một Ubuntu VPS, chạy một graph mẫu ở chế độ headless bằng systemd, rồi ghi event stream dạng machine-readable vào một log file để bạn có thể tìm kiếm khi một lần chạy bị lỗi lúc 3 giờ sáng.

Các thành phần đều nhỏ gọn. Bun chạy CLI. Một coding agent CLI, Codex hoặc Claude Code, thực hiện phần model work. Một npm package được pin version chứa runtime. Một file TypeScript chứa graph của bạn. Một systemd service và timer chạy graph theo lịch. Phần lớn nội dung tập trung vào những vấn đề thường thực sự gây lỗi: PATH bên trong systemd unit, credential của agent trong session không có login shell và việc pin dependency được phát hành lần đầu vào July 2026.

Visual builder, code hay chỉ prompt trực tiếp cho agent

Người tự host hệ thống tự động hóa công việc bằng model thường chọn một trong ba dạng. Mỗi dạng gặp lỗi theo cách khác nhau.

Visual builder cung cấp canvas, thư viện node và giao diện mà người không lập trình cũng có thể sử dụng. Đây là một lợi thế thực tế. Thị trường hiện có đủ nhiều lựa chọn để bạn tham khảo danh sách các lựa chọn thay thế n8n tự host. Đổi lại, logic được lưu thành một tài liệu JSON do UI tạo ra. Diff của tài liệu này thường khó đọc. Vì vậy, muốn review một thay đổi, bạn phải mở canvas thay vì đọc patch.

Prompt trực tiếp cho agent là dạng thứ hai. Bạn mô tả toàn bộ công việc trong một đoạn văn rồi để model tự quyết định thứ tự thực hiện, số lần retry và thời điểm dừng. Cách này hoạt động cho đến khi model quyết định khác đi. Không có diff vì không có artifact. Plan nằm trong cuộc hội thoại, và cuộc hội thoại có thể đã biến mất.

Orchestration bằng code là dạng thứ ba. Thứ tự các bước, fan-out, retry và xử lý lỗi đều là TypeScript thông thường được quản lý trong git. Model chỉ được gọi tại những điểm cần phán đoán, không được gọi ở nơi khác. Đổi lại, phải có người viết và maintain phần code này. Một đồng nghiệp không viết TypeScript sẽ không thể chỉnh sửa nó.

Lợi ích và chi phí của graph runtime

  • Luồng điều khiển có thể review. Graph là một file. Thay đổi trong retry policy hiển thị trong pull request dưới dạng 3 dòng thay đổi, thay vì một box bị di chuyển.
  • Xử lý lỗi được quản lý bằng version control. Điều gì xảy ra khi step 4 thất bại được ghi lại, kiểm thử và gắn tag cùng với phần infrastructure còn lại.
  • Có thể thay agent. Runtime cung cấp sẵn adapter cho Codex, Claude Code và Pi. Để đổi agent chạy một step, chỉ cần thay một import.
  • Có thể theo dõi execution. Runtime xuất phase và event dưới dạng structured data, nên một lần chạy headless vẫn để lại record có thể truy vấn.

Thực hành tổng quát là thiết kế loop mà model chạy bên trong, thay vì chỉ đánh bóng một prompt. Cách này được gọi là thiết kế loop, và graph runtime là một cách cụ thể để thực hiện. Chi phí là phần setup: phải cài runtime, authenticate agent CLI, không có interface cho người không lập trình và phải theo dõi một dependency còn mới.

Dự án còn mới, vì vậy hãy ghim version

Deer Workflow được cấp phép theo MIT và còn mới. Tính đến ngày 19 August 2026, repository có 47 commit trên main. npm lưu giữ 3 version đã publish: 0.0.1 và 0.1.0 vào ngày 26 July 2026, sau đó là 0.2.0 vào ngày 27 July 2026. Mỗi version đều có một git tag, còn changelog cho biết các thay đổi giữa chúng. Section Unreleased đã xóa command deer-workflow agent, vì vậy main và version mới nhất đã publish không còn cung cấp cùng một CLI.

Đó không phải là lý do để tránh dùng dự án này. Đây là lý do để cài đặt một version chính xác và biết mình đã cài version nào.

  • Cài đặt một version chính xác, không dùng range.
  • Ghi lại version đó trong cùng repository với các graph của bạn.
  • Sau mỗi lần upgrade, hãy chạy thủ công một graph của bạn trước khi timer chạy lại.

Cài đặt Bun và một agent runtime

Mọi lệnh bên dưới đều chạy bằng user thông thường có quyền sudo. Không chạy bằng root. Các agent CLI lưu thông tin xác thực trong thư mục home của user đã đăng nhập. Unit systemd về sau cũng phải chạy bằng chính user đó để tìm được thông tin này.

sudo apt update
sudo apt install -y curl unzip jq git nodejs npm
curl -fsSL https://bun.com/install | bash

Bun installer giải nén một zip archive, nên trước hết phải có unzip. Installer thêm các dòng PATH vào shell profile của bạn. Shell hiện tại đã đọc file đó, vì vậy hãy mở một shell mới hoặc tự thêm 2 dòng sau vào ~/.bashrc rồi reload file.

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$HOME/.npm-global/bin:$PATH"
bun --version

Lệnh này in ra số version. bun: command not found có nghĩa là shell hiện tại chưa có dòng PATH tương ứng, không phải quá trình cài đặt đã thất bại. Chạy ls ~/.bun/bin trước khi cài đặt lại bất kỳ thứ gì.

Tiếp theo là agent runtime. Codex CLI là lựa chọn mặc định và được cài đặt từ npm. Đặt npm prefix ở cấp user để cài đặt global không cần root.

npm config set prefix "$HOME/.npm-global"
npm install -g @openai/codex
command -v codex
codex

command -v codex phải in ra một path bên dưới $HOME/.npm-global/bin. Tự chạy codex sẽ mở CLI, nơi bạn đăng nhập bằng tài khoản ChatGPT. Hãy thực hiện việc này ngay bây giờ, khi bạn còn nhìn thấy màn hình.

Claude Code là một runtime thay thế và có installer riêng.

curl -fsSL https://claude.ai/install.sh | bash
claude --version

Cài đặt thành công sẽ in ra một version như 2.1.211 (Claude Code). Chạy claude một lần để đăng nhập. Đây là cùng một loại process, với quyền truy cập vào các file của bạn tương tự bất kỳ agent nào khác mà bạn host. Vì vậy, các lưu ý về account và hardening trong chạy coding agent trên VPS cũng áp dụng nguyên vẹn ở đây.

Cài Deer Workflow và cố định đúng version

bun install --global @deerwork-ai/deer-workflow@0.2.0
command -v deer-workflow

command -v in ra đường dẫn tuyệt đối, thường là /home/<your user>/.bun/bin/deer-workflow. Hãy sao chép đường dẫn này ra một nơi khác. systemd unit không thể dùng tên ngắn.

Giữ version trong lệnh cài đặt. Bỏ @0.2.0 sẽ cài bản mới nhất tại thời điểm bạn chạy lệnh. Với một project có 47 commit, điều này có thể làm thay đổi CLI trong một timer mà không ai monitor.

Đưa các graph vào một git repository

mkdir -p ~/workflows/logs
cd ~/workflows
git init

Codex kiểm tra xem nó có đang chạy bên trong một git repository hay không. Vì vậy, CodexAgentConfig có tùy chọn skipGitRepositoryCheck cho trường hợp bạn không thể cung cấp repository. Trên VPS của bạn, bạn có thể cung cấp một repository và nên làm vậy: graph là code, còn lập luận dùng code để viết orchestration sẽ không còn hiệu lực nếu code không được quản lý bằng version control. Hãy tạo thư mục logs ngay bây giờ, vì systemd sẽ không tự tạo thư mục này cho bạn.

Viết một graph

Một workflow là một module TypeScript thông thường. Module này export meta, một object chứa tên, mô tả và danh sách phase theo thứ tự; đồng thời export handler dưới dạng default hoặc một named run export. Bên trong handler, bạn gọi các helper từ package. phase() đánh dấu stage hiện tại của run, log() ghi một dòng tiến độ, agent() gửi một prompt đến coding agent, parallel() chạy đồng thời một danh sách task, còn pipeline() đưa một danh sách item qua nhiều stage.

Lưu file này thành ~/workflows/log-triage.ts.

import { agent, log, parallel, phase } from "@deerwork-ai/deer-workflow";

export const meta = {
  name: "log-triage",
  description: "Groups recent service errors and writes one short report.",
  phases: [{ title: "Collect" }, { title: "Classify" }, { title: "Report" }],
  exampleArgs: { service: "nginx", hours: 24 },
};

export default async function workflow(args: { service: string; hours: number }) {
  if (!args?.service) throw new Error("input needs a service name");

  phase("Collect");
  log(`Reading ${args.hours}h of logs for ${args.service}`);
  const found = await agent<{ patterns: string[] }>(
    `Read the last ${args.hours} hours of journalctl -u ${args.service} and list the distinct error patterns.`,
    {
      sandbox: "read-only",
      schema: {
        type: "object",
        properties: { patterns: { type: "array", items: { type: "string" } } },
        required: ["patterns"],
        additionalProperties: false,
      },
    },
  );

  phase("Classify");
  log(`Classifying ${found.patterns.length} patterns`);
  const notes = await parallel(
    found.patterns.map((pattern) => () =>
      agent(`Explain this error and its most likely cause: ${pattern}`, { sandbox: "read-only" }),
    ),
  );

  phase("Report");
  return agent(`Write a short operations report from these notes: ${JSON.stringify(notes.filter(Boolean))}`);
}

Bốn chi tiết trong file này có vai trò quan trọng.

  • schema trong một lời gọi agent() yêu cầu structured output, và lời gọi đó trả về object đã được parse. found.patterns là một array thật mà phần còn lại của graph có thể lặp qua. Nếu không có schema, agent() trả về một string và bạn phải tự parse phần văn bản.
  • sandbox quyết định step đó được phép thao tác với những gì. read-only chặn thao tác ghi, workspace-write cho phép ghi có kiểm soát, còn danger-full-access loại bỏ lớp kiểm soát đó. Thiết lập này áp dụng cho từng lời gọi, nên một graph có thể đọc rộng nhưng chỉ ghi ở một nơi.
  • parallel() nhận các function, không nhận promise. map((pattern) => () => agent(...)) tạo một danh sách thunk để runtime quyết định thời điểm bắt đầu từng task. Nếu truyền trực tiếp agent(...), mọi lời gọi sẽ bắt đầu ngay khi danh sách được tạo.
  • Một task bị lỗi bên trong parallel() sẽ trở thành null và run vẫn tiếp tục, vì thiết kế này cho phép hoàn thành một phần. Vì vậy notes.filter(Boolean) không phải nội dung trang trí: nếu bỏ qua nó, một branch bị lỗi sẽ đưa chuỗi null vào prompt của step tiếp theo.

Helper agent() thông thường sử dụng runtime mặc định là Codex. Để gửi một step đến Claude Code thay vì Codex, hãy import agent class rồi gọi trực tiếp.

import { ClaudeAgent } from "@deerwork-ai/deer-workflow";

const claude = new ClaudeAgent({ sandbox: "read-only" });
const summary = await claude.run<string>("Summarise ./report.md in five lines.");

Đó là cách một agent có thể thay thế lẫn nhau trong thực tế: chỉ cần một import và một constructor, còn graph xung quanh không thay đổi. Flag --agent codex|claude|pi trên CLI thuộc về deer-workflow create, dùng để tạo file workflow từ một mô tả. Flag này không thay đổi runtime mà deer-workflow run sử dụng.

Chạy thủ công một lần, sau đó chạy headless

cd ~/workflows
deer-workflow run ./log-triage.ts --input '{"service":"nginx","hours":24}'

Khi chạy tương tác, bạn có giao diện terminal: các phase từ meta ở một bên và log trực tiếp ở bên còn lại. Hãy theo dõi một lần chạy hoàn chỉnh theo cách này trước khi tự động hóa. Nếu agent chưa đăng nhập hoặc input không khớp với handler signature, bạn sẽ thấy lỗi trong vài giây thay vì đến tuần sau mới phát hiện trong file log.

Để tự động hóa, hãy chuyển input vào một file. Lưu ~/workflows/input.json:

{ "service": "nginx", "hours": 24 }
deer-workflow run ./log-triage.ts --input-file ./input.json --print >> logs/run.jsonl

--print, dạng viết tắt là -p, tắt giao diện và ghi event stream vào stdout, mỗi dòng một JSON object. Ở chế độ này, stdout không chứa thêm nội dung nào khác, nên ghi trực tiếp vào file .jsonl sẽ tạo ra một file trong đó mọi dòng đều parse được.

Luồng sự kiện và những gì cần grep lúc 3 giờ sáng

Mỗi dòng chứa type, sequence, timestamp, workflowId, depthscriptPath. Các loại gồm workflow:start, workflow:meta, workflow:end, workflow:error, workflow:phase:start, workflow:phase:endlog. Sự kiện phase chứa phase, sự kiện kết thúc chứa durationMs, sự kiện log chứa message, còn sự kiện workflow:error chứa error cùng với name, message và thường là stack.

Cấu trúc này đủ để trả lời hai câu hỏi lúc 3 giờ sáng: tiến trình đã hoàn tất chưa và nó dừng ở đâu.

grep workflow:error logs/run.jsonl
jq -r 'select(.type == "workflow:error") | .error.message' logs/run.jsonl
jq -r 'select(.type == "workflow:phase:end") | [.phase, .durationMs] | @tsv' logs/run.jsonl
jq -r 'select(.type == "log") | .message' logs/run.jsonl

Để theo dõi một run đang diễn ra, hãy follow file: tail -f logs/run.jsonl | jq -c 'select(.type == "log")'. Mỗi run chỉ ghi một số dòng nhỏ, nhưng file chỉ tăng lên và không tự giảm, vì vậy hãy thêm rule logrotate cho ~/workflows/logs/*.jsonl sau khi timer đã chạy được vài tuần.

Chạy bằng systemd

Dùng một service oneshot cùng với một timer thay vì một daemon chạy liên tục. Graph khởi động, chạy rồi thoát. Tạo /etc/systemd/system/log-triage.service, thay deploy bằng user của bạn.

[Unit]
Description=Log triage workflow
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/home/deploy/workflows
Environment=HOME=/home/deploy
Environment=PATH=/home/deploy/.bun/bin:/home/deploy/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/deploy/.bun/bin/deer-workflow run ./log-triage.ts --input-file ./input.json --print
StandardOutput=append:/home/deploy/workflows/logs/run.jsonl
StandardError=journal
TimeoutStartSec=3600

Sau đó chạy /etc/systemd/system/log-triage.timer:

[Unit]
Description=Run the log triage workflow every night

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl start log-triage.service
systemctl status log-triage.service
sudo systemctl enable --now log-triage.timer
systemctl list-timers log-triage.timer

Trước tiên, hãy chạy service thủ công. Một lần chạy bình thường sẽ kết thúc với unit được deactive thành công, và logs/run.jsonl sẽ có thêm một loạt event kết thúc bằng workflow:end. Chỉ bật timer sau khi đã xác nhận bước này. list-timers hiển thị lần chạy tiếp theo theo lịch, còn Persistent=true có nghĩa là một lần chạy bị bỏ lỡ khi server tắt sẽ được thực hiện một lần vào lần boot tiếp theo. StandardOutput=append: ghi event stream vào file và để journal xử lý mọi thứ khác, nhờ đó journalctl -u log-triage.service vẫn dễ đọc.

Vì sao graph chạy được trong shell nhưng lại fail khi chạy dưới systemd?

Hãy kiểm tra 4 mục sau, theo đúng thứ tự này.

Unit không tìm thấy các binary. systemd không bao giờ đọc ~/.bashrc, và PATH mặc định của nó không chứa ~/.bun/bin hoặc ~/.npm-global/bin. Unit fail trong chưa đầy 1 giây, còn journalctl -u log-triage.service cho biết lệnh exec fail ngay trên tên command. Vì vậy ExecStart dùng đường dẫn tuyệt đối, và Environment=PATH= vẫn liệt kê cả 2 thư mục: chính runtime phải tìm thấy codex hoặc claude khi khởi động một agent step.

Agent không tìm thấy credentials. Agent CLI đọc thông tin đăng nhập từ home directory, vì vậy hãy đặt rõ User=Environment=HOME=, đồng thời cấp cho agent đúng home directory mà bạn đã dùng để đăng nhập. Nếu một lần chạy đã đến workflow:start rồi tạo ra workflow:error có message xuất phát từ agent CLI thay vì từ code của bạn, thì gần như chắc chắn nguyên nhân là mục này.

Lần chạy bị kill sau 90 giây. Với Type=oneshot, systemd áp dụng start timeout cho toàn bộ command, và giá trị mặc định là 90 giây. Một agent graph cần vài phút. Journal ghi Start operation timed out. Terminating., unit chuyển sang trạng thái failed, còn file log chỉ chứa một nửa lần chạy và không có workflow:end. TimeoutStartSec=3600 tăng thời gian này lên 1 giờ. Dùng infinity nếu bạn muốn process không bao giờ bị kill do timeout.

Relative path được resolve ở nơi khác. ./log-triage.ts./input.json là relative path tính từ WorkingDirectory. Nếu bỏ dòng đó, systemd sẽ khởi động process trong /, nơi không có file nào trong 2 file này.

Orchestrator được phép làm gì

Orchestrator chạy các bước của agent theo timer là một process hoạt động trên server của bạn mà không có người giám sát. Có 2 cơ chế kiểm soát và một khoản ngân sách cần quản lý.

Cơ chế đầu tiên là sandbox trên mỗi lần gọi agent(). read-only là mặc định phù hợp cho mọi bước chỉ đọc dữ liệu: log, metric hoặc repository mà bạn đang tóm tắt. Chuyển một bước sang workspace-write khi bước đó thực sự cần ghi dữ liệu, và giới hạn vùng có quyền ghi bằng additionalWritableDirectories thay vì dùng ngay danger-full-access.

Cơ chế thứ hai là con người. Một số bước không được chạy unattended: gửi mail, chuyển tiền, xóa dữ liệu hoặc thay đổi cấu hình production. Trong graph ưu tiên code, bạn dễ đặt gate vì mỗi bước là một dòng code. Dừng lần chạy, ghi lại action được đề xuất, chờ người dùng trả lời rồi tiếp tục. Đặt approval gate trước các action của agent trình bày đầy đủ pattern này và nên được dùng trong mọi graph được timer khởi chạy.

Ngân sách là tiền. Mỗi lần gọi agent() là một agent session đầy đủ, còn parallel() khởi chạy nhiều session cùng lúc. Vì vậy, graph tách thành 12 nhánh sẽ chạy 12 session mỗi đêm, dù không ai đọc report. Các phép đo và giới hạn trong kiểm soát chi phí AI agent trên VPS được áp dụng trực tiếp cho graph chạy theo lịch.

Trước khi upgrade runtime, hãy đọc changelog, cài đúng version mới rồi chạy graph một lần bằng tay với --print. Dự án còn mới nên CLI surface vẫn đang thay đổi: section Unreleased đã bỏ một command tồn tại trong 0.2.0. Graph chạy dưới timer chỉ đáng tin cậy ngang với version bạn đã pin và lần chạy gần nhất mà bạn thực sự theo dõi.

FAQ

Tôi cần Bun hay Node.js sẽ chạy được Deer Workflow?

Hãy cài Bun. Package đã phát hành trỏ binary deer-workflow vào src/cli.ts, một file mã nguồn TypeScript, và tài liệu liệt kê Bun là điều kiện tiên quyết. Bun chạy trực tiếp TypeScript nên không cần bước build. Cài bằng sudo apt install -y unzip rồi chạy curl -fsSL https://bun.com/install | bash, sau đó xác nhận bằng bun --version. Bạn vẫn cần cài riêng Node.js và npm nếu cài Codex CLI từ npm.

Vì sao workflow chạy được trong terminal nhưng lại lỗi dưới systemd?

Gần như luôn là do PATH, HOME hoặc timeout khởi động. systemd không đọc shell profile của bạn, nên ExecStart cần đường dẫn tuyệt đối đến deer-workflow, còn Environment=PATH= cần thư mục chứa codex hoặc claude. Agent CLI đọc credential từ $HOME, vì vậy hãy đặt User=Environment=HOME= cho account bạn đã đăng nhập. Ngoài ra, Type=oneshot có timeout khởi động mặc định là 90 giây. Timeout này có thể dừng agent run giữa chừng và ghi Start operation timed out. Terminating. vào journal, vì vậy hãy đặt TimeoutStartSec=3600.

Làm cách nào để dùng Claude Code thay cho Codex trong một step?

Helper agent() thông thường dùng runtime mặc định là Codex. Hãy import ClaudeAgent từ package, khởi tạo nó rồi gọi .run() cho các step bạn muốn Claude Code xử lý. Flag --agent codex|claude|pi thuộc về deer-workflow create, command tạo file workflow từ một mô tả, và không ảnh hưởng đến deer-workflow run. Agent bạn dùng phải có CLI riêng đã được cài đặt và đăng nhập bằng cùng user mà service chạy dưới quyền user đó.

Tôi nên cài phiên bản Deer Workflow nào?

Hãy cài đúng phiên bản bạn đã test. Tính đến ngày 19 tháng 8 năm 2026, phiên bản mới nhất đã phát hành là 0.2.0, phát hành ngày 27 tháng 7 năm 2026, và repository có 47 commit. Ghi @0.2.0, hoặc phiên bản hiện tại tại thời điểm bạn đọc nội dung này, vào install command. Lưu số phiên bản đó trong git cùng với các graph của bạn. Sau mỗi lần upgrade, hãy chạy thử một graph bằng tay trước khi timer chạy lại.