Ruby 3.4 with Rails 8 on Ubuntu 24.04 on Azure User Guide
Overview
Ruby on Rails is the convention-over-configuration web framework that pioneered modern MVC web development — ActiveRecord ORM, ActionView templates, ActionCable websockets, ActiveJob background queues, scaffolded CRUD generators. The cloudimg image installs Ruby 3.4.1 (source-compiled via ruby-build into /opt/ruby) and Rails 8.0.5 with gems vendored under /opt/rails/cloudimg/vendor/bundle. Puma 6 fronts the app on a Unix socket, nginx reverse-proxies on TCP :80 and serves compiled assets directly, and PostgreSQL 16 is the production database. A cloudimg starter project at /opt/rails/cloudimg includes a scaffolded Article model with full CRUD UI at /articles and an Admin model seeded at first boot.
What is included:
- Ruby 3.4.1 (BSD 2-Clause + custom Ruby License) source-compiled at
/opt/ruby, with/usr/local/bin/{ruby,gem,bundle}symlinks - Rails 8.0.5 (MIT) with gems vendored under
/opt/rails/cloudimg/vendor/bundle - Puma 6 binding to
/run/puma/cloudimg.sock(2 workers, 2-8 threads by default) - nginx reverse proxy on TCP
:80(proxy_pass to the puma socket, serves/assets/frompublic/assets/) - PostgreSQL 16 from Ubuntu noble main, with a per-VM
cloudimgrole +cloudimg_productiondatabase - cloudimg starter project at
/opt/rails/cloudimgwith a scaffoldedArticleresource (full CRUD at/articles) Adminmodel withhas_secure_password(bcrypt) seeded withemail: cloudimg- Per-VM
SECRET_KEY_BASE(64 hex), DB password (32 hex), Admin password (32 hex) rotated at first boot - systemd units:
puma.service,rails-firstboot.service, plusnginx.serviceandpostgresql.service - 24/7 cloudimg support
Prerequisites
Active Azure subscription, SSH key, VNet + subnet. Standard_B2s (4 GB RAM) is comfortable for a 2-worker Puma + Postgres. NSG inbound: allow 22/tcp from your management CIDR and 80/tcp from any client CIDR that needs the Rails UI.
Step 1-3: Deploy + SSH (standard pattern)
ssh azureuser@<vm-ip>
Step 4: Service Status + Versions
sudo systemctl is-active puma.service nginx.service postgresql.service
/opt/ruby/bin/ruby --version
cd /opt/rails/cloudimg && sudo -u rails /opt/ruby/bin/bundle exec bin/rails --version

Step 5: Read Per-VM Credentials
sudo cat /stage/scripts/rails-credentials.log
Pick up RAILS_URL, RAILS_ARTICLES_URL, RAILS_ADMIN_USER, RAILS_ADMIN_EMAIL, RAILS_ADMIN_PASSWORD. Also includes the per-VM RAILS_DB_NAME, RAILS_DB_USER, and RAILS_DB_PASSWORD. The file is mode 0600 and readable only by root.
Step 6: HTTP Health + Static Files
curl -sf -o /dev/null -w 'root: HTTP %{http_code}\n' http://127.0.0.1/
curl -sf -o /dev/null -w '/articles: HTTP %{http_code}\n' http://127.0.0.1/articles
ASSET=$(sudo find /opt/rails/cloudimg/public/assets -type f \( -name '*.css' -o -name '*.js' \) 2>/dev/null | head -1)
if [ -n "$ASSET" ]; then curl -sf -o /dev/null -w "asset: HTTP %{http_code}\n" "http://127.0.0.1/assets/$(basename "$ASSET")"; fi
The first two calls hit Puma through nginx. The third proves nginx is serving precompiled assets directly from public/assets/ (faster than going through Rails).

Step 7: Browse the Rails Articles Index
Browse to http://<vm-ip>/ — the root path routes to articles#index. On a fresh boot the list is empty.

Step 8: Create an Article
Click New article, fill in the title and body fields, and submit.

Step 9: View the Created Article
After submission the scaffold redirects to the article detail page, where you can edit or destroy.

Step 10: Run Rails Console / Management Commands
set -a; . /etc/rails/cloudimg.env; set +a
cd /opt/rails/cloudimg
sudo -E -u rails /opt/ruby/bin/bundle exec bin/rails runner 'puts "article-count:" + Article.count.to_s'
sudo -E -u rails /opt/ruby/bin/bundle exec bin/rails runner 'puts "admin-count:" + Admin.count.to_s'
For an interactive console use sudo -E -u rails /opt/ruby/bin/bundle exec bin/rails console. bin/rails db:migrate is idempotent — already-applied migrations are no-ops.
Step 11: Add a Rails Generator (model/scaffold)
Replace <Resource> and the field list with your own:
cd /opt/rails/cloudimg
sudo -u rails /opt/ruby/bin/bundle exec bin/rails generate scaffold Comment body:text article:references
sudo -u rails /opt/ruby/bin/bundle exec bin/rails db:migrate
sudo -u rails /opt/ruby/bin/bundle exec bin/rails assets:precompile
sudo systemctl restart puma.service
The scaffold emits routes, a controller, views, and a migration. After db:migrate and assets:precompile, restart Puma so the new code is loaded.
Step 12: Tune Puma Workers
Edit /etc/rails/cloudimg.env to raise PUMA_WORKERS, then restart:
sudo systemctl restart puma.service
Defaults on Standard_B2s:
PUMA_WORKERS:2PUMA_THREADS_MIN:2PUMA_THREADS_MAX:8RAILS_ENV:productionRAILS_LOG_TO_STDOUT:trueRAILS_SERVE_STATIC_FILES:true- DB: PostgreSQL 16, role
cloudimg, dbcloudimg_production
For production raise workers to 2 × CPU + 1 (e.g. 9 on a D4s_v5).
Step 13: Add Your Domain to Allowed Hosts
Rails 8 ships with Rails.application.config.hosts empty by default in production, which means host authorization is not enforced (unlike Django's strict ALLOWED_HOSTS). If you enable host authorization, add your domain in config/environments/production.rb:
Rails.application.config.hosts << "apps.example.com"
Then restart Puma:
sudo systemctl restart puma.service
For most setups behind nginx no change is needed — the proxy passes the original Host header through and Rails accepts it.
Step 14: Install Let's Encrypt SSL
Point your domain DNS A record at this VM, then:
sudo apt-get install -y python3-certbot-nginx
sudo certbot --nginx -d apps.example.com -m you@example.com --agree-tos --non-interactive --redirect
Certbot edits /etc/nginx/sites-available/rails-cloudimg to add the SSL server block and a :80 → :443 redirect, then reloads nginx. Renewal is via the certbot.timer systemd unit (runs twice daily).
Step 15: Backups
Take a consistent backup with the app running by pg_dump-ing the database, plus the ActiveStorage storage/ directory:
PASS=$(sudo grep '^RAILS_DB_PASSWORD=' /stage/scripts/rails-credentials.log | cut -d= -f2-)
PGPASSWORD="$PASS" pg_dump -h 127.0.0.1 -U cloudimg cloudimg_production | gzip > /var/backups/cloudimg-db-$(date +%F).sql.gz
sudo tar czf /var/backups/cloudimg-storage-$(date +%F).tgz -C /opt/rails/cloudimg storage
Periodically copy /var/backups to Azure Blob Storage (az storage blob upload-batch) for off-VM retention.
Step 16: Logs and Troubleshooting
sudo journalctl -u puma.service --no-pager -n 80
sudo journalctl -u nginx.service --no-pager -n 30
sudo journalctl -u postgresql.service --no-pager -n 30
sudo tail -50 /var/log/nginx/access.log
sudo tail -50 /var/log/nginx/error.log
RAILS_LOG_TO_STDOUT=true sends Rails logs to the systemd journal via Puma's stdout. For more verbose output during debugging set RAILS_ENV=development in /etc/rails/cloudimg.env and restart Puma — but never in production, as it disables view caching, exposes stack traces, and reloads classes on every request.
Security
RAILS_ENV=productionby default — production-safe out of the box (no debug stack traces, view caching on)- Per-VM
SECRET_KEY_BASE(64 hex chars fromopenssl rand) - Puma binds to a Unix socket (not exposed to the network)
- PostgreSQL listens on
127.0.0.1:5432(loopback only) - Per-VM DB password (32 hex chars)
- Per-VM Admin password (32 hex chars), bcrypt-hashed via
has_secure_password - Credentials log at
/stage/scripts/rails-credentials.logis mode0600(root-only) - Restrict NSG inbound on
:80to the IP ranges that need the Rails app - Install
python3-certbot-nginxfor Let's Encrypt automation (Step 14)
Support
cloudimg provides 24/7/365 expert technical support. Guaranteed response within 24 hours, one hour average for critical issues. Contact support@cloudimg.co.uk.