Cài GitHub Actions runner trên VPS Ubuntu 24.04
Hướng dẫn đăng ký self-hosted GitHub Actions runner trên Ubuntu 24.04, dùng user riêng, kiểm tra checksum, config.sh, systemd và xử lý rủi ro fork pull request.
Self-hosted GitHub Actions runner làm gì
Self-hosted GitHub Actions runner là một chương trình bạn cài trên VPS của mình. Nó yêu cầu GitHub cung cấp job rồi chạy job trên phần cứng của bạn. Bạn đăng ký runner với một repository, cài nó dưới dạng systemd service, và runner sẽ khởi động lại sau mỗi lần reboot. GitHub lập lịch cho job. Server của bạn thực hiện công việc.
CI (continuous integration) trên máy chủ bạn sở hữu có hai lợi ích chính. Build minutes không còn bị tính theo hạn mức, và job có thể truy cập những tài nguyên chỉ máy của bạn có, chẳng hạn build cache đã được làm nóng hoặc private network. Đổi lại là rủi ro bảo mật. Runner thực thi mọi nội dung workflow file chỉ định, với user mà bạn cấp cho nó. Vì vậy, về bản chất, workflow file cho phép thực thi mã từ xa. Với private repository, điều này chấp nhận được vì chỉ những người bạn tin tưởng mới có thể thêm workflow file. Với public repository, đây là rủi ro thực sự. Phần nói về pull request từ fork sẽ giải thích cơ chế này.
Toàn bộ nội dung dưới đây dùng Ubuntu 24.04 và runner version 2.336.0, là bản release hiện tại tính đến tháng 7 năm 2026.
Bạn cần chuẩn bị gì trước khi bắt đầu
Bắt đầu với một VPS có tài khoản admin thông thường và quyền sudo, ở trạng thái bạn đạt được trong mười phút đầu tiên trên một VPS mới. Bạn không cần mở cổng inbound. Runner mở một kết nối HTTPS (hypertext transfer protocol secure) outbound đến GitHub và giữ kết nối đó trong khi chờ công việc, nên GitHub không bao giờ kết nối đến server của bạn. Firewall có thể vẫn chặn kết nối từ Internet, nhưng các job vẫn đến được.
Bạn cũng cần quyền admin trên repository vì registration token được hiển thị trong phần cài đặt của repository.
Tạo user riêng cho runner
Không bao giờ chạy runner bằng root hoặc user admin của bạn. Mỗi job kế thừa quyền của user chạy runner, nên workflow gọi sudo sẽ thành công nếu user này được phép dùng sudo. Tạo một user không có quyền đặc biệt và chỉ sở hữu thư mục home của chính nó. Tài khoản user với quyền tối thiểu trên VPS trình bày mẫu cấu hình chung. Dưới đây là cấu hình cụ thể.
sudo useradd -m -s /bin/bash gharunner
sudo passwd -l gharunner
sudo chmod 750 /home/gharunner
sudo install -d -m 700 -o gharunner -g gharunner /home/gharunner/actions-runnerpasswd -l khóa mật khẩu, nên không ai có thể dùng mật khẩu đó để đăng nhập bằng gharunner. Quyền 700 trên thư mục runner rất quan trọng vì runner lưu credential ở đó dưới dạng văn bản thuần túy, và một lần checkout có thể chứa source riêng tư.
Kiểm tra cả hai thuộc tính trước khi tiếp tục:
sudo passwd -S gharunner
sudo -l -U gharunnerpasswd -S in ra một dòng bắt đầu bằng gharunner L, trong đó L nghĩa là mật khẩu đã bị khóa. sudo -l -U gharunner phải trả về is not allowed to run sudo. Nếu lệnh in ra danh sách các command được phép thay vì vậy, account này đang thuộc một sudo group và cơ chế cô lập vừa tạo đã không còn hiệu lực.
Tải runner và kiểm tra tarball
Từ đây, hãy làm việc với user runner.
sudo -iu gharunner
cd ~/actions-runner
RUNNER_VERSION=2.336.0
curl -fL -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"Chạy uname -m trước nếu bạn không chắc về architecture. x86_64 sử dụng file linux-x64 ở trên. aarch64 sử dụng actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz.
Bây giờ hãy xác minh file đã tải xuống. SHA256 (secure hash algorithm, 256 bit) bên dưới dành cho tarball 2.336.0 x64. GitHub hiển thị giá trị của release hiện tại trên trang release và màn hình New self-hosted runner. Giá trị này thay đổi theo từng version, vì vậy hãy copy giá trị từ đó khi cài version khác.
echo "04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d actions-runner-linux-x64-2.336.0.tar.gz" | sha256sum -cDownload hợp lệ sẽ in ra một dòng:
actions-runner-linux-x64-2.336.0.tar.gz: OKFile bị cắt ngắn hoặc bị thay đổi sẽ in ra lỗi và cảnh báo:
actions-runner-linux-x64-2.336.0.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT matchKhông được bỏ qua bước kiểm tra rồi để tar phát hiện vấn đề sau. Archive được ghi dở sẽ fail với gzip: stdin: unexpected end of file và tar: Unexpected EOF in archive. Các thông báo này cho biết file bị hỏng, nhưng không cho biết file bị cắt ngắn hay đã bị thay thế.
tar xzf ./actions-runner-linux-x64-2.336.0.tar.gz
lsNội dung tarball và những gì không có
Sau khi giải nén, thư mục chứa config.sh, run.sh, env.sh, safe_sleep.sh, bin/ và externals/. bin/ chứa các binary của runner và bin/installdependencies.sh. externals/ chứa Node runtime đi kèm mà các action JavaScript thực thi.
Hiện chưa có svc.sh. Tài liệu của GitHub mô tả đây là script “được tạo sau khi thêm runner thành công”, vì script này được tạo từ một template với repository và tên runner được đưa vào tên service. Do đó, sudo ./svc.sh install trước ./config.sh sẽ thất bại với sudo: ./svc.sh: command not found. Hãy đăng ký trước, rồi cài service.
Cài đặt các dependency của runner
Runner là một ứng dụng .NET nên cần một số shared library. Giữ shell của user chạy runner và dùng sudo để cài đặt, vì script ghi vào cơ sở dữ liệu package của hệ thống.
exit
cd /home/gharunner/actions-runner
sudo ./bin/installdependencies.shTrên Ubuntu 24.04, lệnh này cài libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64 và libicu74. Script thử nhiều tên phiên bản cho từng library và giữ lại tên mà bản phát hành của bạn cung cấp. Vì vậy, cùng một script hoạt động trên Ubuntu cũ hơn và Debian.
Nếu bỏ qua bước này, ./config.sh sẽ dừng trước khi thực hiện thao tác nào:
Dependencies is missing for Dotnet Core 6.0
Execute sudo ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.Thiếu libicu cũng cho cùng khuyến nghị, nhưng dòng đầu tiên khác là Libicu's dependencies is missing for Dotnet Core 6.0. Cả hai lỗi đều xuất phát từ cùng một nguyên nhân: trước khi khởi động, config.sh chạy ldd đối với các library đi kèm. Vì vậy, liên kết không được phân giải sẽ khiến script dừng, thay vì gây ra lỗi crash khó xác định về sau.
Đăng ký runner với repository
Lấy token từ repository. Mở Settings, rồi chọn Actions, Runners, New self-hosted runner. Trang này hiển thị một registration token bắt đầu bằng A. Token hết hạn sau một giờ kể từ khi được tạo, vì vậy hãy tạo token khi bạn sẵn sàng dán token vào lệnh.
Đăng ký bằng runner user. config.sh không chạy được khi dùng sudo.
sudo -iu gharunner
cd ~/actions-runner
./config.sh --url https://github.com/YOUR-USER/YOUR-REPO \
--token PASTE_REGISTRATION_TOKEN_HERE \
--name vps-runner-1 \
--labels vps \
--work _work \
--unattended \
--replaceCác flag này có tác dụng như sau. --name là tên runner hiển thị trong repository, vì vậy hãy chọn tên mà sau sáu tháng bạn vẫn nhận ra. --labels thêm label riêng của bạn; runner đã có sẵn self-hosted, Linux và X64 mà không cần chỉ định. --work đặt tên cho thư mục chứa các bản checkout, nằm bên trong runner directory. --unattended trả lời các prompt tương tác bằng giá trị mặc định, phù hợp khi lệnh nằm trong script. --replace tiếp quản registration hiện có cùng tên thay vì báo lỗi, phù hợp khi bạn dựng lại server.
Lệnh chạy thành công sẽ kết thúc bằng các dòng sau:
√ Runner successfully added
√ Runner connection is good
√ Settings Saved.Thông tin registration hiện được lưu trong runner directory dưới dạng .runner, .credentials và .credentials_rsaparams. Hai file cuối dùng để nhận diện runner này với GitHub, vì vậy bất kỳ ai đọc được chúng đều có thể giả mạo runner. Vì lý do đó, directory có mode 700 và user không có quyền sudo.
Cài runner dưới dạng systemd service
Chạy ./run.sh trong terminal phù hợp cho một lần kiểm tra, nhưng tiến trình sẽ dừng cùng phiên SSH của bạn. Hãy cài service để runner tự khởi động khi boot. systemd service và timer trên VPS giải thích về chính các unit file. Ở đây, svc.sh sẽ tự tạo unit file cho bạn.
exit
cd /home/gharunner/actions-runner
sudo ./svc.sh install gharunner
sudo ./svc.sh start
sudo ./svc.sh statussvc.sh yêu cầu quyền root vì script này ghi một unit vào /etc/systemd/system và enable unit đó. Đối số sau install là user mà service sẽ chạy bằng. Hãy truyền rõ gharunner. Nếu không có đối số, script sẽ dùng $SUDO_USER, tức tài khoản admin của bạn, khiến mọi job chạy bằng user có thể dùng sudo.
Unit được đặt tên theo repository và runner, theo dạng actions.runner.YOUR-USER-YOUR-REPO.vps-runner-1.service. Bạn không cần tự nhập tên này:
systemctl list-units 'actions.runner.*'
sudo journalctl -u 'actions.runner.*' -n 20 --no-pagerRunner hoạt động bình thường sẽ ghi log √ Connected to GitHub, sau đó là một dòng kết thúc bằng Listening for Jobs; trang Runners của repository sẽ hiển thị runner ở trạng thái Idle. Runner hiển thị là Offline nghĩa là runner không chạy hoặc không thể kết nối đến GitHub qua cổng 443.
Gửi job đến runner
runs-on chọn runner theo label. Hãy dùng self-hosted cùng với label của bạn để job không chạy trên runner ngoài dự kiến.
name: build
on:
push:
branches: [main]
jobs:
build:
runs-on: [self-hosted, linux, vps]
steps:
- uses: actions/checkout@v5
- run: uname -aNếu job chờ tại Waiting for a runner to pick up this job, các label không khớp. Mọi label trong runs-on đều phải tồn tại trên runner. Chỉ cần thừa một từ, job sẽ tiếp tục nằm trong hàng đợi mà không có lỗi ở đâu. So sánh danh sách này với các label hiển thị bên cạnh runner trong phần cài đặt repository.
Vì sao self-hosted runner và repository công khai không nên dùng chung
Đây là phần nhiều người bỏ qua. Hướng dẫn của GitHub nêu rất rõ: self-hosted runner “hầu như không bao giờ nên được dùng cho repository công khai” và GitHub “không đảm bảo runner sẽ chạy trong các máy ảo tạm thời, sạch, đồng thời runner có thể bị mã độc từ code không tin cậy trong workflow xâm nhập lâu dài”.
Cơ chế này rất đơn giản. Pull request từ fork mang theo bản sao riêng của file workflow. Nếu repository công khai của bạn chạy workflow cho pull request trên runner của mình, bất kỳ ai có thể fork repository đều có thể đề xuất một workflow chạy lệnh của họ trên VPS của bạn. Họ không cần quyền ghi, vì thứ họ đề xuất chính là thứ sẽ được chạy.
Các thiết lập phê duyệt chỉ giảm rủi ro chứ không giải quyết vấn đề. Chính sách mặc định của repository công khai yêu cầu maintainer phê duyệt workflow từ fork của contributor gửi pull request lần đầu. Sau khi bạn phê duyệt người đó một lần, các pull request sau của họ sẽ chạy mà không cần prompt mới. Vì vậy, lớp kiểm soát chỉ là một người đọc diff mỗi lần. Một payload nằm sâu ba cấp trong build script rất dễ bị bỏ sót.
Pull request từ fork không nhận được secrets của bạn, và GITHUB_TOKEN của nó chỉ có quyền đọc. Điều này giới hạn mức độ thiệt hại bên trong GitHub. Nhưng nó không bảo vệ server của bạn. Kẻ tấn công có shell với tư cách gharunner. Chúng có thể đọc mọi file mà user đó được phép đọc, truy cập mọi tài nguyên VPS có thể truy cập trên private network, và để lại mã độc trong ~/.bashrc hoặc trong một user systemd unit được chạy ở job tiếp theo.
Đăng ký bằng --ephemeral khiến runner nhận một job rồi tự deregister, nên một job không thể đọc workspace của job tiếp theo. Cách này chỉ có tác dụng nếu có thành phần rebuild máy hoặc container cho từng job. Nếu không, backdoor được ghi vào home directory của runner user vẫn tồn tại sau khi đăng ký lại.
Các quy tắc tiếp theo rất ngắn gọn. Hãy dùng self-hosted runner cho repository riêng tư. Nếu bắt buộc phải gắn runner vào repository công khai, không chạy pull request từ fork trên runner đó, không chạy thêm bất kỳ thứ gì khác trên server, và coi máy này là tài nguyên có thể bỏ đi.
Docker jobs và group thực chất có quyền root
Các job chạy trong container, service container và mọi bước workflow gọi docker build đều cần Docker daemon trên host của runner. Cài Docker theo cách thông thường, như hướng dẫn Cài Docker và Docker Compose trên VPS, sau đó thêm user chạy runner vào group docker.
Hãy hiểu rõ đánh đổi trước khi thực hiện. Thành viên của group docker tương đương với quyền root, vì container có thể bind mount / và chạy với quyền root bên trong container. Vì vậy, workflow có thể giao tiếp với Docker socket sẽ đọc và ghi được mọi file trên VPS, bao gồm cả /etc/shadow. Với repository private và các contributor đáng tin cậy, đây có thể là một đánh đổi chấp nhận được. Trong mọi trường hợp khác, việc này làm mất ý nghĩa của user không có đặc quyền. Rootless Docker giữ các bản build container trong phạm vi quyền của chính user chạy runner, nhưng storage driver sẽ chậm hơn và không hỗ trợ privileged container.
Cập nhật và gỡ runner đúng cách
Self-hosted runner tự cập nhật theo mặc định. Runner phát hiện bản phát hành mới, thay thế các file của chính nó rồi khởi động lại service, nên thông thường bạn không cần làm gì. ./config.sh --disableupdate tắt tính năng tự cập nhật khi bạn cần cố định một phiên bản. Sau đó, bạn phải tự cập nhật: tài liệu của GitHub nêu rõ runner được cấu hình với --disableupdate phải được cập nhật thủ công.
Cập nhật thủ công vẫn giữ nguyên registration, vì .runner và .credentials không nằm trong tarball. Dừng service, tải xuống và kiểm tra checksum của tarball mới theo gharunner, giải nén đè lên cùng thư mục bằng tar xzf, rồi khởi động lại service:
cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh startĐể gỡ runner, trước tiên hãy uninstall service, sau đó deregister runner. Removal token lấy từ cùng trang Runners, bên dưới nút Remove của chính runner đó.
cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh uninstall
sudo -iu gharunner
cd ~/actions-runner
./config.sh remove --token PASTE_REMOVAL_TOKEN_HEREXóa thư mục mà không deregister runner sẽ khiến runner vẫn hiển thị là Offline trong repository, vì GitHub chỉ biết runner đã bị xóa khi runner tự báo trạng thái đó hoặc admin xóa entry thủ công.
Các lỗi thường gặp và chuỗi thông báo bạn sẽ thấy
Must not run with sudo. config.sh in thông báo này rồi thoát khi được chạy bằng root. Việc kiểm tra này là có chủ đích, vì các file do root sở hữu trong _work sẽ làm hỏng mọi job chạy sau đó bằng user của service. Chạy ./config.sh bằng gharunner. Biến RUNNER_ALLOW_RUNASROOT bỏ qua bước kiểm tra này, nhưng chỉ đẩy lỗi sang giai đoạn sau.
sudo: ./svc.sh: command not found. Bạn đang ở đúng thư mục. svc.sh chưa tồn tại vì config.sh chưa hoàn tất việc đăng ký. Hãy đăng ký runner, sau đó cài đặt service.
Http response code: NotFound from 'POST https://api.github.com/actions/runner-registration'. Token không phải là registration token hợp lệ. Token có thể đã hết hạn vì chỉ có hiệu lực trong một giờ, hoặc bạn đã dán personal access token thay cho registration token trên trang Runners. Tạo token mới rồi dán lại.
Dependencies is missing for Dotnet Core 6.0. Chạy sudo ./bin/installdependencies.sh từ thư mục runner bằng root, sau đó đăng ký lại.
Runner Offline sau khi reboot. Chạy systemctl is-enabled 'actions.runner.*'. Nếu không có gì được liệt kê, ./svc.sh install chưa từng được chạy, nên runner chỉ tồn tại trong terminal session của bạn. Nếu unit đã được enable nhưng runner vẫn Offline, đọc journalctl -u 'actions.runner.*' và kiểm tra kết nối HTTPS outbound.
Ổ đĩa đầy. Các checkout, build cache và Docker image tích lũy trong _work và thư mục home của user chạy runner; không có cơ chế nào tự dọn chúng. Theo dõi du -sh /home/gharunner/actions-runner/_work và thêm tác vụ dọn dẹp theo lịch trước khi ổ đĩa tự gây ra sự cố.
FAQ
Vì sao sudo ./svc.sh install báo command not found?
Vì svc.sh không có trong tarball của runner. Nó được tạo trong thư mục runner khi ./config.sh đăng ký xong, bằng cách dùng tên repository và runner của bạn để tạo tên service. Trước tiên, hãy chạy ./config.sh bằng user chạy runner. Sau đó, sudo ./svc.sh install gharunner sẽ tìm thấy script và ghi một unit có tên actions.runner.OWNER-REPO.RUNNER-NAME.service vào /etc/systemd/system.
Tôi có cần mở cổng firewall cho self-hosted runner không?
Không. Runner mở một kết nối HTTPS outbound đến GitHub và giữ kết nối đó trong khi chờ job, nên GitHub không bao giờ chủ động mở kết nối đến VPS của bạn. Cho phép outbound 443 và giữ các rule inbound ở trạng thái đóng. Nếu runner hiển thị Offline trong khi service vẫn đang chạy, hãy kiểm tra lọc outbound và DNS thay vì các rule inbound.
Tôi có thể dùng self-hosted runner trên repository public không?
Có, nhưng GitHub khuyến cáo không nên làm vậy. Pull request từ một fork chứa file workflow riêng, nên bất kỳ ai có thể fork repository của bạn đều có thể đề xuất các lệnh chạy trên máy của bạn. Prompt phê duyệt chỉ áp dụng cho lần chạy đầu tiên của contributor. Nếu gắn runner vào một repository public, hãy tắt workflow pull request từ fork trên runner đó, không chạy thêm thành phần nào khác trên server, và định kỳ rebuild máy.
Vì sao đăng ký thất bại với Http response code: NotFound?
Lệnh gọi đăng ký trả về NotFound khi credential không đúng, không chỉ khi URL sai, nên thông báo này dễ gây hiểu nhầm. Registration token hết hạn một giờ sau khi được hiển thị, và personal access token không được chấp nhận cho lệnh gọi này. Mở lại Settings, Actions, Runners, New self-hosted runner, sao chép token mới, rồi xác nhận giá trị --url trỏ đến một repository mà bạn có quyền admin.