Post

Deploy Rails 8.1 with Kamal on a VM (Experimental)

Deploy Rails 8.1 with Kamal on a VM (Experimental)

หลังจาก Rails 8.1 ปล่อยออกมา ฟีเจอร์ที่รออยู่นานคือ Registry-Free Kamal Deployments

ปกติแล้ว Kamal ต้องการ Docker registry (Docker Hub, GHCR, registry.digitalocean.com, ฯลฯ) เป็นตัวกลาง — เครื่อง dev docker push ขึ้นไป แล้ว server docker pull ลงมารัน ปัญหาคือ Docker Hub แบบ free มี private repo ได้แค่ 1 ตัว ส่วน GHCR ก็ผูกอยู่กับ GitHub ถ้ามีหลายแอปต้องจ่ายเงินหรือเปลี่ยน provider

Kamal 2.7+ ที่มากับ Rails 8.1 แก้ปัญหานี้ด้วยการ run Docker registry container บน deploy server เลย (default ที่ localhost:5555) — Kamal build image บนเครื่อง dev แล้ว push ตรงไปที่ registry บน server ผ่าน SSH tunnel ไม่ต้องพึ่ง registry ภายนอกเลย

โพสต์นี้พา deploy Rails 8.1 บน VM ด้วยวิธีนี้ — ใช้ได้ทั้ง VM ที่บ้าน, DigitalOcean Droplet, Hetzner, หรือ VPS provider อื่นที่ให้เราเข้าถึง Ubuntu ผ่าน SSH

Prerequisites

  • Rails 8.1+ บนเครื่อง dev
  • Ruby 3.3+
  • VM/Server (Ubuntu 22.04+ แนะนำ) ที่เข้าถึงด้วย SSH ได้ พร้อม sudo access
  • Docker Desktop หรือ OrbStack บนเครื่อง dev (สำหรับ build image)

Getting Started

ในการทดลองนี้จะลอง deploy บน VM ก่อน ซึ่งน่าจะเป็นแนวทางในการ deploy บน server จริง ๆ ของเรา หรือบน cloud service ที่มีการจัดการ VPS ให้เรา อย่างเช่น DigitalOcean

Create a Server

ติดตั้ง SSH server และตั้ง firewall ให้เปิดเฉพาะ port ที่จำเป็น:

1
2
3
4
5
6
7
8
sudo apt install openssh-server ufw
sudo ufw status
sudo ufw enable
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo systemctl restart ssh
sudo systemctl status ssh

UFW (Uncomplicated Firewall) default จะ deny ทุก inbound port — ต้อง allow ssh (22), http (80), และ https (443) เพราะ Kamal-proxy ต้องการ 80/443 สำหรับ serve app และ Let’s Encrypt cert (ถ้าเปิดใช้ SSL)

Create Deploy User

ห้ามใช้ root deploy โดยตรง — สร้าง user แยกชื่อ deploy:

1
sudo adduser deploy

เพิ่ม deploy เข้า sudo group เพื่อให้รันคำสั่ง privileged ได้ (Kamal ต้องการตอน install Docker):

1
sudo adduser deploy sudo

login เข้า deploy user:

1
su deploy

เพิ่ม group docker และใส่ deploy เข้ากลุ่ม เพื่อให้รัน docker ได้โดยไม่ต้อง sudo:

1
2
sudo addgroup docker
sudo usermod -aG docker $USER

ต้อง logout แล้ว login ใหม่ (เพื่อให้สิทธิ์กลุ่ม docker มีผล)

สร้าง directory สำหรับ storage ของ Rails application (จะถูก bind-mount เข้า container ทีหลัง):

1
mkdir blog_storage

แก้ไฟล์ sudoers ให้ deploy รัน sudo ได้โดยไม่ต้องใส่ password:

1
sudo visudo

เพิ่ม deploy ALL=(ALL) NOPASSWD:ALL หลังบรรทัด %sudo ALL=(ALL:ALL) ALL:

1
2
3
4
# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) ALL

deploy  ALL=(ALL) NOPASSWD:ALL

ทำไมต้อง NOPASSWD — Kamal ssh เข้าไปรัน command แบบ non-interactive ไม่มีทาง input password ระหว่าง deploy ถ้าไม่ตั้ง NOPASSWD ทุก task ที่ใช้ sudo จะค้าง

Add SSH Key for Deploy

Using ssh-copy-id

1
brew install ssh-copy-id
1
ssh-copy-id deploy@1.2.3.4
1
ssh deploy@1.2.3.4

Using authorized_keys

อ่าน public key บนเครื่อง dev:

1
2
# for RSA SSH key
cat ~/.ssh/id_rsa.pub
1
2
# for ED25519 SSH key
cat ~/.ssh/id_ed25519.pub

บน server สร้างไฟล์ authorized_keys ของ deploy user:

1
mkdir ~/.ssh && touch ~/.ssh/authorized_keys
1
sudo vi ~/.ssh/authorized_keys

paste public key ลงไป:

1
ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX user@computer

ทดสอบจากเครื่อง dev:

1
ssh deploy@1.2.3.4

Install Docker Desktop or OrbStack

บนเครื่อง dev เลือกอย่างใดอย่างหนึ่ง:

1
brew install --cask docker-desktop

หรือ (เร็วกว่า ใช้ resource น้อยกว่าบน macOS):

1
brew install --cask orbstack

Create Rails Application

1
2
3
rails new blog
cd blog
rails generate controller home index

ตั้ง root route:

1
root "home#index"

Configuring Kamal

แก้ config/deploy.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
 # Name of your application. Used to uniquely configure containers.
 service: blog

 # Name of the container image (use your-user/app-name on external registries).
 image: blog

 # Deploy to these servers.
 servers:
   web:
-    - 192.168.0.1
+    - [ip_address ของ server]
   # job:
   #   hosts:
   #     - 192.168.0.1
   #   cmd: bin/jobs

 # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
 # ...
 # proxy:
 #   ssl: true
 #   host: app.example.com

 # Where you keep your container images.
 registry:
   # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
   server: localhost:5555

   # Needed for authenticated registries.
   # username: your-user

   # Always use an access token rather than real password when possible.
   # password:
   #   - KAMAL_REGISTRY_PASSWORD

 # Inject ENV variables into containers (secrets come from .kamal/secrets).
 env:
   secret:
     - RAILS_MASTER_KEY
   clear:
     SOLID_QUEUE_IN_PUMA: true

 aliases:
   console: app exec --interactive --reuse "bin/rails console"
   shell: app exec --interactive --reuse "bash"
   logs: app logs -f
   dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"

 # Use a persistent storage volume for sqlite database files and local Active Storage files.
 volumes:
-  - "blog_storage:/rails/storage"
+  - "./blog_storage:/rails/storage"

 asset_path: /rails/public/assets

 builder:
   arch: amd64

-# ssh:
-#   user: app
+ssh:
+  user: deploy

จุดสำคัญในไฟล์นี้:

  • registry: server: localhost:5555 — บรรทัดที่เป็นหัวใจของ Registry-Free Deployment — บอก Kamal ให้ใช้ registry ที่รันอยู่บน deploy server (Kamal จะตั้ง container kamal-docker-registry ให้เองตอน kamal setup) Kamal push image ผ่าน SSH tunnel เข้า port 5555 บน server โดยตรง ไม่ผ่าน internet
  • volumes: ./blog_storage:/rails/storage — เปลี่ยนจาก Docker named volume (blog_storage:) เป็น bind mount โฟลเดอร์ host (./blog_storage) ที่สร้างไว้ใน home ของ deploy user — backup ง่ายกว่า เห็นไฟล์จริงบน host เลย และคุม permission ได้ตรงๆ
  • ssh: user: deploy — สั่ง Kamal ssh ด้วย user deploy ที่เพิ่งสร้าง ไม่ใช่ root
  • env.secret: RAILS_MASTER_KEY — ดึงค่ามาจาก .kamal/secrets (ส่วนถัดไป) ไม่ใช่ฮาร์ดโค้ดใน yaml

ตั้ง .kamal/secrets

ไฟล์นี้คือ bash script ที่ Kamal source ก่อน deploy เพื่อโหลด secret ลง environment ค่าจะถูกส่งเข้า container ตอน start ไม่ได้ commit ลง repo

1
RAILS_MASTER_KEY=$(cat config/master.key)

ถ้าใช้ password manager (เช่น 1Password CLI) แทน:

1
RAILS_MASTER_KEY=$(op read "op://Vault/blog/RAILS_MASTER_KEY")

Setup และ Deploy

commit ทุกอย่างให้เรียบร้อย แล้วรัน:

1
kamal setup

kamal setup ทำงานหลายอย่างในคำสั่งเดียว:

  1. ssh เข้า server เป็น deploy user
  2. ติดตั้ง Docker บน server (ถ้ายังไม่มี)
  3. ตั้ง kamal-proxy container (Traefik รุ่นเบาที่ Kamal เขียนเอง) ฟัง port 80/443
  4. ตั้ง local Docker registry container ที่ port 5555
  5. build image บนเครื่อง dev แล้ว push ผ่าน SSH tunnel ไปที่ local registry บน server
  6. pull image จาก local registry แล้ว start app container
  7. ผูก app container เข้ากับ kamal-proxy

หลัง setup เสร็จเปิด browser ไปที่ http://<ip ของ server> จะเห็นหน้า home

ครั้งต่อไปใช้:

1
kamal deploy

แค่ build + push + restart container ไม่ต้องตั้ง infra ใหม่

Verify

ดู log ของ app:

1
kamal app logs -f

เช็ค container ที่รันบน server:

1
docker ps

ควรเห็น kamal-proxy, kamal-docker-registry, และ blog-web-<version> รันอยู่

Troubleshooting

Permission denied (publickey) ตอน Kamal ssh เข้า server SSH key บนเครื่อง dev ยังไม่ได้ copy ไปที่ ~/.ssh/authorized_keys ของ deploy user ลอง ssh deploy@<ip> ด้วยมือก่อน ถ้าผ่านค่อย retry kamal setup

sudo: a terminal is required to read the password ลืม NOPASSWD ใน /etc/sudoers Kamal จะค้างรอ password ที่ไม่มีทาง input เปิด visudo เพิ่มบรรทัด deploy ALL=(ALL) NOPASSWD:ALL แล้ว retry

Browser เข้า http://<ip> แล้ว timeout UFW block — เช็คด้วย sudo ufw status ต้องมีทั้ง 80/tcp ALLOW และ 443/tcp ALLOW

kamal-proxy start ไม่ได้ — port already in use มี service อื่นบน host ใช้ port 80/443 อยู่ (Apache, Nginx ที่ติดมาจาก default) สั่งหยุดและ disable ก่อน:

1
2
sudo systemctl stop apache2 nginx
sudo systemctl disable apache2 nginx

Image push ช้ามาก Registry-Free Kamal push image ผ่าน SSH tunnel ซึ่งช้ากว่า push ตรงไป Docker Hub สำหรับ build ขนาดใหญ่ ลองใช้ builder.remote ตามตัวอย่างใน deploy.yml (build บน remote arm/amd64 server) เพื่อข้าม transfer image ข้าม internet

Conclusion

ได้ Rails 8.1 รันบน server ด้วย Kamal โดยไม่ต้องสมัคร registry ภายนอกเลย — ทุกอย่างอยู่บน server เดียว: app, kamal-proxy, local registry

Pattern นี้เหมาะกับ:

  • Single-server deployment (1 VM/VPS รันแอปเดียว)
  • Side project ที่ไม่อยากจ่ายค่า Docker Hub Pro
  • Self-host บน home server หรือ Raspberry Pi

กลับไปใช้ external registry เมื่อ:

  • มี server หลายตัวต้อง pull image ชุดเดียวกัน
  • ต้องการ rollback ไป image version เก่าๆ ที่ไม่ได้เก็บบน server ปัจจุบัน
  • ใช้ CI/CD pipeline ที่ build บน GitHub Actions/GitLab CI แล้ว push ขึ้น registry กลาง

References

This post is licensed under CC BY 4.0 by the author.