├── .gitignore ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── exe └── hamal ├── hamal.gemspec └── lib └── hamal.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | SuggestExtensions: false 3 | NewCops: enable 4 | 5 | # I can set other things than accessors. 6 | Naming/AccessorMethodName: 7 | Enabled: false 8 | 9 | # Don't complain on missing documentation for every class. 10 | Style/Documentation: 11 | Enabled: false 12 | 13 | # No frozen string literals 14 | Style/FrozenStringLiteralComment: 15 | EnforcedStyle: never 16 | 17 | # Let me write them typographic (–) dashes in comments. 18 | Style/AsciiComments: 19 | Enabled: false 20 | 21 | # Don't enforce Kernel#lambda. 22 | Style/Lambda: 23 | Enabled: false 24 | 25 | # Since module_function is a visibility modifier, you can't have private 26 | # singleton methods. E.g. in some cases, we _do_ need to use extend self. 27 | Style/ModuleFunction: 28 | Enabled: false 29 | 30 | # I like them and am gonna use them in multiline blocks. A nice way to enable 31 | # their usage by disabling their cop. 32 | Style/NumberedParameters: 33 | Enabled: false 34 | 35 | # Let me use and/or precedence in conditions, please! 36 | Style/AndOr: 37 | Enabled: false 38 | 39 | # I think this results in uglier code, depending on the situation. 40 | # 41 | # Example: 42 | # 43 | # if old_password or password 44 | # change_password(old_password, password) 45 | # end 46 | # 47 | # Versus: 48 | # 49 | # change_password(old_password, password) if old_password or password 50 | # 51 | # It depends, but if I have a complex condition or a longer line, I prefer the 52 | # more explicit if condition. 53 | Style/IfUnlessModifier: 54 | Enabled: false 55 | 56 | # Recently, I tend to prefer the named boolean operators. Yeah, they do have 57 | # different precedence, but still. 58 | Style/Not: 59 | Enabled: false 60 | 61 | # I don't think this results in better code. We're flatting it out, while it is 62 | # really nested. Why hide that? 63 | Style/GuardClause: 64 | Enabled: false 65 | 66 | Style/StabbyLambdaParentheses: 67 | Enabled: false 68 | 69 | # Some of our admin code is generated by administrate. I don't wanna change 70 | # this autogenerated code. 71 | Style/TrailingCommaInHashLiteral: 72 | Enabled: false 73 | 74 | # Some of our admin code is generated by administrate. I don't wanna change 75 | # this autogenerated code. 76 | Style/TrailingCommaInArguments: 77 | Enabled: false 78 | 79 | # Some of our admin code is generated by administrate. I don't wanna change 80 | # this autogenerated code. 81 | Style/SymbolArray: 82 | Enabled: false 83 | 84 | # Prefer double quotes because at this time I like them quite better. 85 | Style/StringLiterals: 86 | EnforcedStyle: double_quotes 87 | 88 | Style/AccessModifierDeclarations: 89 | Enabled: false 90 | 91 | Style/ClassAndModuleChildren: 92 | Enabled: false 93 | 94 | # Dogfood the no-parens love. 95 | Style/MethodCallWithArgsParentheses: 96 | Enabled: true 97 | EnforcedStyle: omit_parentheses 98 | AllowParenthesesInMultilineCall: true 99 | AllowParenthesesInChaining: true 100 | AllowParenthesesInCamelCaseMethod: true 101 | 102 | Style/MutableConstant: 103 | Enabled: false 104 | 105 | # I like the value omission hash syntax. 106 | Style/HashSyntax: 107 | EnforcedShorthandSyntax: always 108 | 109 | # This is an application, not a library. We don't need to go that far. 110 | Style/DocumentDynamicEvalDefinition: 111 | Enabled: false 112 | 113 | # I do that a lot. Think it's okay. 114 | Lint/AssignmentInCondition: 115 | Enabled: false 116 | 117 | # Let's not enforce arbitrary metrics. 118 | Metrics/MethodLength: 119 | Enabled: false 120 | 121 | Metrics/ClassLength: 122 | Enabled: false 123 | 124 | Metrics/BlockLength: 125 | Enabled: false 126 | 127 | Metrics/AbcSize: 128 | Enabled: false 129 | 130 | Metrics/ParameterLists: 131 | Enabled: false 132 | 133 | Metrics/PerceivedComplexity: 134 | Enabled: false 135 | 136 | Metrics/CyclomaticComplexity: 137 | Enabled: false 138 | 139 | # Sometimes life leaves you no choice. True story. 140 | Lint/SuppressedException: 141 | Enabled: false 142 | 143 | Layout/DefEndAlignment: 144 | EnforcedStyleAlignWith: start_of_line 145 | 146 | # I think it's safe to ignore the 80 chars limit. 147 | Layout/LineLength: 148 | Enabled: false 149 | 150 | Layout/LineContinuationLeadingSpace: 151 | Enabled: false 152 | 153 | Layout/MultilineMethodCallIndentation: 154 | EnforcedStyle: indented 155 | 156 | Layout/SpaceInLambdaLiteral: 157 | Enabled: false 158 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in hamal.gemspec 6 | gemspec 7 | 8 | gem "rake" 9 | gem "rubocop" 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | hamal (0.1.1) 5 | json 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.2) 11 | json (2.9.1) 12 | language_server-protocol (3.17.0.3) 13 | parallel (1.26.3) 14 | parser (3.3.6.0) 15 | ast (~> 2.4.1) 16 | racc 17 | racc (1.8.1) 18 | rainbow (3.1.1) 19 | rake (13.2.1) 20 | regexp_parser (2.10.0) 21 | rubocop (1.68.0) 22 | json (~> 2.3) 23 | language_server-protocol (>= 3.17.0) 24 | parallel (~> 1.10) 25 | parser (>= 3.3.0.2) 26 | rainbow (>= 2.2.2, < 4.0) 27 | regexp_parser (>= 2.4, < 3.0) 28 | rubocop-ast (>= 1.32.2, < 2.0) 29 | ruby-progressbar (~> 1.7) 30 | unicode-display_width (>= 2.4.0, < 3.0) 31 | rubocop-ast (1.37.0) 32 | parser (>= 3.3.1.0) 33 | ruby-progressbar (1.13.0) 34 | unicode-display_width (2.6.0) 35 | 36 | PLATFORMS 37 | arm64-darwin-23 38 | ruby 39 | 40 | DEPENDENCIES 41 | hamal! 42 | rake 43 | rubocop 44 | 45 | BUNDLED WITH 46 | 2.6.2 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hamal is a simple deploy tool for self-hosted Rails applications. Learn how it 2 | works, how to configure it, and how to provision new servers. 3 | 4 | Not to be confused with Kamal. 😉 5 | 6 | **PLACEHOLDERS**: Some commands and configuration snippets described in this 7 | configuration are app-specific, i.e. their exact contents will vary from app to 8 | app. In order to make this documentation generic, the `#{app_name}` and 9 | `#{app_domain}` placeholders are used in such places. Replace the placeholders 10 | with the respective values in `config/deploy.yml` before executing the commands 11 | / copying the configuration. 12 | 13 | # Overview 14 | 15 | `hamal` implements a simple deploy process for a self-hosted app on a server 16 | that you administer. It: 17 | 18 | 1. Connects to the server via SSH. All subsequent stages happen on the server. 19 | 2. Fetches the code that will be deployed from GitHub. 20 | 3. Builds a Docker image containing the app's code. 21 | 4. Uses that image to run the app in a container. 22 | 5. Configures nginx to expose the app container to the Internet. 23 | 24 | ## Prerequisites 25 | 26 | To deploy the app to a server, you will need: 27 | 28 | - A bare-bones Ubuntu 22.04 server. 29 | - SSH access as `root` to that server. 30 | 31 | # Provision 32 | 33 | When you want to deploy the app on a new server, prepare it for service first 34 | by following the steps in this section. 35 | 36 | ## System settings (on the server) 37 | 38 | ### Update packages 39 | 40 | Install latest updates. You'd likely want to do this periodically. 41 | 42 | ``` 43 | apt update 44 | apt upgrade 45 | ``` 46 | 47 | Note: If provisioning an ARM64 Hetzner server, make sure the mirrors in 48 | `/etc/apt/sources.list` are using `http://mirror.hetzner.com/ubuntu-ports/packages/` 49 | URLs instead of `http://mirror.hetzner.com/ubuntu/packages/` 50 | (see https://status.hetzner.com/incident/43b5f083-cb30-4c01-b904-b611206eb172). 51 | 52 | ### Tighten SSH config 53 | 54 | In `/etc/ssh/sshd_config`: 55 | 56 | - Set `PasswordAuthentication` to `no` 57 | - Comment out the `Subsystem sftp` line 58 | 59 | ### Restart for changes to take effect 60 | 61 | ``` 62 | reboot 63 | ``` 64 | 65 | ## Docker 66 | 67 | Follow the [official docs](https://docs.docker.com/engine/install/ubuntu/). 68 | The following should just work: 69 | 70 | ``` 71 | curl -fsSL https://get.docker.com | sh 72 | ``` 73 | 74 | ## nginx 75 | 76 | ### Install 77 | 78 | Follow the [official docs](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-open-source/#installing-prebuilt-ubuntu-packages). 79 | In short: 80 | 81 | ``` 82 | apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring 83 | curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor | tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null 84 | # Verify keyring (see official docs for that) 85 | echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/ubuntu `lsb_release -cs` nginx" | tee /etc/apt/sources.list.d/nginx.list 86 | echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" | tee /etc/apt/preferences.d/99nginx 87 | apt update 88 | apt install nginx 89 | systemctl start nginx 90 | ``` 91 | 92 | ### Configure (part 1, before we have an SSL certificate) 93 | 94 | We need this first incomplete part of the nginx configuration so that we can 95 | issue an SSL certificate. 96 | 97 | Create the directory that will serve static content for the SSL verification 98 | process: 99 | 100 | ``` 101 | mkdir /usr/share/nginx/cert_validations 102 | ``` 103 | 104 | Replace the contents of `/etc/nginx/nginx.conf` with the following: 105 | 106 | ``` 107 | user nginx; 108 | worker_processes auto; 109 | 110 | error_log /var/log/nginx/error.log notice; 111 | pid /var/run/nginx.pid; 112 | 113 | events { 114 | worker_connections 1024; 115 | } 116 | 117 | http { 118 | include /etc/nginx/mime.types; 119 | default_type application/octet-stream; 120 | 121 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 122 | '$status $body_bytes_sent "$http_referer" ' 123 | '"$http_user_agent" "$http_x_forwarded_for"'; 124 | access_log /var/log/nginx/access.log main; 125 | 126 | sendfile on; 127 | keepalive_timeout 65; 128 | 129 | ssl_session_cache shared:SSL:10m; 130 | ssl_session_timeout 10m; 131 | 132 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 133 | proxy_set_header X-Forwarded-Proto $scheme; 134 | proxy_set_header Host $http_host; 135 | 136 | server { 137 | listen 80; 138 | 139 | location /.well-known/acme-challenge/ { 140 | root /usr/share/nginx/cert_validations; 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | Apply the changes: 147 | 148 | ``` 149 | nginx -s reload 150 | ``` 151 | 152 | ### Issue SSL certificate 153 | 154 | Install certbot: 155 | 156 | ``` 157 | apt install snapd 158 | snap install core 159 | snap refresh core 160 | snap install --classic certbot 161 | ``` 162 | 163 | Issue a certificate: 164 | 165 | ``` 166 | certbot certonly -m genadi@hey.com --webroot -w /usr/share/nginx/cert_validations -d #{app_domain} 167 | ``` 168 | 169 | Let's Encrypt certificates are only valid for 90 days and need to be renewed 170 | regularly. There's no need to manually create a cron, though, the certbot snap 171 | installation has already taken care of this by registering a 172 | `snap.certbot.renew.timer` systemd timer (check `systemctl list-timers`). 173 | 174 | Test that the renewal process is properly set up: 175 | 176 | ``` 177 | certbot renew --dry-run 178 | ``` 179 | 180 | You should see a success message for the certificate we just issued. 181 | 182 | ### Configure (part 2, after we have an SSL certificate) 183 | 184 | Create `/etc/nginx/#{app_name}.conf.template` with the following contents: 185 | 186 | ``` 187 | server { 188 | listen 443 ssl; 189 | server_name #{app_domain}; 190 | 191 | ssl_certificate /etc/letsencrypt/live/#{app_domain}/fullchain.pem; 192 | ssl_certificate_key /etc/letsencrypt/live/#{app_domain}/privkey.pem; 193 | 194 | location / { 195 | proxy_pass http://localhost:$ACTIVE_RAILS_PORT; 196 | } 197 | } 198 | ``` 199 | 200 | Create a temporary dummy `#{app_name}.conf` file. This will get overwritten by 201 | the actual deploy process, but we need it for now to bootstrap nginx with a 202 | valid config: 203 | 204 | ``` 205 | ACTIVE_RAILS_PORT=80 envsubst < /etc/nginx/#{app_name}.conf.template > /etc/nginx/#{app_name}.conf 206 | ``` 207 | 208 | Add the following include line at the end of the `http` block in `/etc/nginx/nginx.conf`: 209 | 210 | ``` 211 | http { 212 | ... 213 | include /etc/nginx/#{app_name}.conf; 214 | } 215 | ``` 216 | 217 | Apply the changes: 218 | 219 | ``` 220 | nginx -s reload 221 | ``` 222 | 223 | ## Deploy user and directories 224 | 225 | - Create app user (with the same UID as the user created in the Dockerfile) 226 | 227 | ``` 228 | useradd rails --uid 1001 --create-home --shell /bin/bash 229 | ``` 230 | 231 | - Create directories 232 | 233 | ``` 234 | mkdir -p /var/lib/#{app_name}/db 235 | mkdir -p /var/lib/#{app_name}/storage 236 | mkdir -p /var/lib/#{app_name}/src 237 | chown rails:rails /var/lib/#{app_name}/db /var/lib/#{app_name}/storage 238 | ``` 239 | 240 | ## Secrets 241 | 242 | Store `RAILS_MASTER_KEY` on the server: 243 | 244 | ``` 245 | echo RAILS_MASTER_KEY= > /var/lib/#{app_name}/env_file 246 | ``` 247 | 248 | ## Database 249 | 250 | If this is an existing app, restore its database to `/var/lib/#{app_name}/db`. 251 | Make sure its owner user and group are `rails:rails`. 252 | 253 | If this is a new app, create its database by running `bin/rails db:create` in 254 | out of its images. You will likely have to do this at a later point, when you 255 | do have such an image. Examine `hamal` to determine what arguments to 256 | `docker run` are needed, e.g. to set ENV variables and mount host directories. 257 | The final commands you're looking for will look something like this: 258 | 259 | ``` 260 | docker run --rm --entrypoint '/rails/bin/rails' -- db:create 261 | docker run --rm --entrypoint '/rails/bin/rails' -- db:schema:load 262 | ``` 263 | 264 | ## GitHub 265 | 266 | Create and add a deploy key to grant the server read-only access to this 267 | repository. Follow the [official docs](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys#deploy-keys). 268 | In short: 269 | 270 | 1. Create a new SSH key for the root user on the server 271 | 272 | ``` 273 | ssh-keygen -t ed25519 -C "Hetzner_" -f ~/.ssh/github_deploy 274 | ``` 275 | 276 | Leave the passphrase empty. 277 | 278 | 2. Add the key to GitHub 279 | 280 | Open repository in GitHub, in "Settings" -> "Deploy keys" press "Add deploy 281 | key". Enter a title (e.g. the `Hetzner_` comment), the public key 282 | you just created (i.e. the contents of `.ssh/github_deploy.pub`), and press 283 | "Add key". 284 | 285 | 3. Configure SSH on the server to use this key when connecting to GitHub 286 | 287 | Create `~/.ssh/config` with the following contents: 288 | 289 | ``` 290 | Host github.com 291 | IdentityFile ~/.ssh/github_deploy 292 | ``` 293 | 294 | # Configuration 295 | 296 | The deploy script expects certain configuration in `config/deploy.yml`: 297 | 298 | - `github_repo`: The repo where the app's source is located, in the form 299 | `/`. 300 | - `app_name`: Used as part of directory and Docker image names, so must be a 301 | valid identifier: only letters, numbers, and underscores. 302 | - `app_domain`: The hostname that this app will be accessible at. 303 | - `server`: The IP address of a provisioned server. 304 | - `local_ports`: An array of at least two ports that will be used by the run 305 | the app locally on the server. These ports will not be exposed to the 306 | Internet. If you're using the server to host multiple apps using this 307 | script, make sure that all apps are configured with unique ports so that 308 | they do not conflict with each other. 309 | 310 | # Usage 311 | 312 | ## Installation 313 | 314 | Install the `hamal` gem globally or put it in your app's `Gemfile`: 315 | 316 | ```ruby 317 | gem "hamal" 318 | ``` 319 | 320 | ## Deploy 321 | 322 | Pass the commit you want deployed to `hamal deploy`: 323 | 324 | ``` 325 | hamal deploy b04c0b567 326 | ``` 327 | 328 | Omitting the commit will deploy the latest commit on the current branch. The 329 | commit must have been pushed to the git repo. The deploy script does not deploy 330 | local commits. 331 | 332 | ## --help 333 | 334 | For more commands, run `hamal --help`: 335 | 336 | ``` 337 | Usage: hamal [command] 338 | 339 | Commands: 340 | deploy - Deploy the app to the server 341 | console - Run Rails console in the deployed container 342 | logs - Follow logs of the deployed container 343 | sudo - SSH into the server as administrator 344 | ``` 345 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rubocop/rake_task" 5 | 6 | RuboCop::RakeTask.new 7 | 8 | task default: :rubocop 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "hamal" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /exe/hamal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "hamal" 4 | 5 | Hamal::Commands.execute 6 | -------------------------------------------------------------------------------- /hamal.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/hamal" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "hamal" 5 | spec.version = Hamal::VERSION 6 | spec.authors = ["Genadi Samokovarov"] 7 | spec.email = ["gsamokovarov@gmail.com"] 8 | 9 | spec.summary = "Hamal is a simple deploy tool for self-hosted Rails applications" 10 | spec.homepage = "https://github.com/gsamokovarov/hamal" 11 | spec.required_ruby_version = ">= 3.1.0" 12 | 13 | spec.metadata["homepage_uri"] = spec.homepage 14 | spec.metadata["source_code_uri"] = "https://github.com/gsamokovarov/hamal" 15 | spec.metadata["changelog_uri"] = "https://github.com/gsamokovarov/hamal/releases" 16 | 17 | spec.files = Dir.chdir(__dir__) do 18 | `git ls-files -z`.split("\x0").reject do |f| 19 | (File.expand_path(f) == __FILE__) || 20 | f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) 21 | end 22 | end 23 | 24 | spec.bindir = "exe" 25 | spec.executables = "hamal" 26 | spec.require_paths = ["lib"] 27 | 28 | spec.add_dependency "json" 29 | 30 | spec.metadata["rubygems_mfa_required"] = "true" 31 | end 32 | -------------------------------------------------------------------------------- /lib/hamal.rb: -------------------------------------------------------------------------------- 1 | require "English" 2 | require "json" 3 | require "yaml" 4 | 5 | module Hamal 6 | VERSION = "0.1.1" 7 | 8 | module Config 9 | def config_file = "config/deploy.yml" 10 | def deployed_revision = ARGV.first.then { _1 unless _1.to_s.start_with? "-" } || `git rev-parse HEAD`.strip 11 | def deployed_image = "#{app_name}:#{deployed_revision}" 12 | def deploy_config = @deploy_config ||= YAML.safe_load_file(config_file) 13 | def deploy_env = "production" 14 | def app_name = deploy_config.fetch "app_name" 15 | def app_repo = deploy_config.fetch "github_repo" 16 | def app_local_ports = deploy_config.fetch("local_ports").map(&:to_s) 17 | def server = deploy_config.fetch "server" 18 | def project_root = "/var/lib/#{app_name}" 19 | end 20 | 21 | module Helpers 22 | include Config 23 | 24 | def on_server(user: :root, dir: nil, &) = RemoteExecutor.new(user, dir).instance_exec(&) 25 | 26 | def log(message) 27 | bold = "\e[1m" 28 | green = "\e[32m" 29 | clear = "\e[0m" 30 | 31 | message = "#{bold}#{green}#{message}#{clear}" if $stdout.tty? 32 | puts message 33 | end 34 | end 35 | 36 | class RemoteExecutor 37 | include Helpers 38 | 39 | ExecResult = Struct.new :output, :exit_code do 40 | def success? = exit_code.zero? 41 | end 42 | 43 | def initialize(remote_user, remote_dir) 44 | @remote_user = remote_user 45 | @remote_dir = remote_dir 46 | 47 | raise "Invalid remote user #{@remote_user}" unless [:root, :rails].include? @remote_user 48 | end 49 | 50 | def sh(command, interactive: false, abort_on_error: false) 51 | dir_override = "cd #{@remote_dir};" if @remote_dir 52 | user_override = "runuser -u rails" if @remote_user == :rails 53 | ssh "#{dir_override} #{user_override} #{command}", interactive:, abort_on_error: 54 | end 55 | 56 | def sh!(command, interactive: false) = sh command, interactive:, abort_on_error: true 57 | 58 | def ssh(remote_command, abort_on_error:, interactive: false) 59 | remote_command = remote_command.gsub "'", %q('"'"') 60 | 61 | output = 62 | if interactive 63 | spawn "ssh -tt root@#{server} '#{remote_command}'", out: $stdout, err: $stderr, in: $stdin 64 | Process.wait 65 | nil 66 | else 67 | `ssh root@#{server} '#{remote_command}'`.strip 68 | end 69 | 70 | abort "Failed to execute `#{remote_command}` on `#{server}`" if abort_on_error && !$CHILD_STATUS.success? 71 | 72 | ExecResult.new output:, exit_code: $CHILD_STATUS.exitstatus 73 | end 74 | end 75 | 76 | module Stages 77 | include Helpers 78 | 79 | def build_new_image 80 | image_exists = on_server { sh "docker image inspect #{deployed_image}" }.success? 81 | if image_exists 82 | log "Using existing image #{deployed_image} for deploy" 83 | return 84 | end 85 | 86 | log "Building new image #{deployed_image} for deploy" 87 | 88 | source_dir = "#{project_root}/src/#{deployed_revision}" 89 | 90 | on_server do 91 | log " Checking out source at revision #{deployed_revision}..." 92 | sh! "rm -rf #{source_dir}" 93 | sh! "git clone git@github.com:#{app_repo}.git #{source_dir}" 94 | end 95 | on_server dir: source_dir do 96 | sh! "git checkout #{deployed_revision}" 97 | 98 | log " Building image..." 99 | sh! "docker build -t #{deployed_image} ." 100 | 101 | log " Cleaning up source dir..." 102 | sh! "rm -rf #{source_dir}" 103 | end 104 | end 105 | 106 | def run_deploy_tasks 107 | log "Running migrations" 108 | 109 | on_server do 110 | sh! "docker run --rm " \ 111 | "--label app=#{app_name} " \ 112 | "--env-file #{project_root}/env_file " \ 113 | "-e GIT_REVISION=#{deployed_revision} " \ 114 | "-v #{project_root}/db:/rails/db/#{deploy_env} " \ 115 | "-v #{project_root}/storage:/rails/storage " \ 116 | "--entrypoint '/rails/bin/rails' " \ 117 | "#{deployed_image} " \ 118 | "-- db:migrate" 119 | end 120 | end 121 | 122 | def start_new_container 123 | log "Starting container for new version" 124 | 125 | # Determine which ports are currently bound and which are free for the new container 126 | running_containers = on_server { sh! "docker ps -q --filter label=app=#{app_name}" }.output.split 127 | bound_ports = 128 | running_containers.map do |container| 129 | port_settings = on_server { sh! "docker inspect --format '{{json .NetworkSettings.Ports}}' #{container}" }.output 130 | port_settings = JSON.parse port_settings 131 | (port_settings["3000/tcp"] || []).map { _1["HostPort"] }.compact 132 | end.flatten 133 | 134 | available_port = (app_local_ports - bound_ports).first 135 | abort "No TCP port available" unless available_port 136 | 137 | log " Using port #{available_port} for new container" 138 | on_server do 139 | sh! "docker run -d --rm " \ 140 | "--label app=#{app_name} " \ 141 | "--env-file #{project_root}/env_file " \ 142 | "-e GIT_REVISION=#{deployed_revision} " \ 143 | "-v #{project_root}/db:/rails/db/#{deploy_env} " \ 144 | "-v #{project_root}/storage:/rails/storage " \ 145 | "-p 127.0.0.1:#{available_port}:3000 " \ 146 | "#{deployed_image}" 147 | end 148 | 149 | [available_port, running_containers] 150 | end 151 | 152 | def switch_traffic(new_container_port) 153 | log "Switching traffic to new version" 154 | 155 | log " Waiting for new version to become ready" 156 | health_checks = 1 157 | loop do 158 | new_container_ready = on_server { sh "curl -fs http://localhost:#{new_container_port}/healthz" }.success? 159 | break if new_container_ready 160 | 161 | abort "New container failed to start within 30 seconds, investigate!" if health_checks > 30 162 | 163 | health_checks += 1 164 | sleep 1 165 | end 166 | 167 | log " Redirecting nginx to new version" 168 | on_server do 169 | sh! "ACTIVE_RAILS_PORT=#{new_container_port} envsubst < /etc/nginx/#{app_name}.conf.template > /etc/nginx/#{app_name}.conf" 170 | sh! "nginx -s reload" 171 | end 172 | end 173 | 174 | def stop_old_container(old_containers) 175 | log "Stopping old container" 176 | 177 | if old_containers.empty? 178 | log " (none found)" 179 | return 180 | end 181 | 182 | on_server do 183 | sh! "docker kill -s SIGTERM #{old_containers.join ' '}" 184 | end 185 | end 186 | 187 | def clean_up 188 | log "Cleaning up" 189 | 190 | log " Removing unused docker objects" 191 | on_server do 192 | sh! 'docker system prune --all --force --filter "until=24h"' 193 | end 194 | end 195 | end 196 | 197 | module Commands 198 | extend self 199 | 200 | include Stages 201 | 202 | def execute 203 | abort "Configure server in deploy config file" unless server 204 | 205 | case ARGV.shift 206 | when "deploy" 207 | deploy_command 208 | when "console" 209 | console_command 210 | when "logs" 211 | logs_command 212 | when "sudo" 213 | sudo_command 214 | else 215 | help_command 216 | end 217 | end 218 | 219 | private 220 | 221 | def deploy_command 222 | build_new_image 223 | run_deploy_tasks 224 | new_container_port, old_containers = start_new_container 225 | switch_traffic new_container_port 226 | stop_old_container old_containers 227 | clean_up 228 | end 229 | 230 | def console_command 231 | image_exists = on_server { sh "docker image inspect #{deployed_image}" }.success? 232 | unless image_exists 233 | log "Cannot find #{deployed_image} for inspecting" 234 | return 235 | end 236 | 237 | log "Running Rails console" 238 | 239 | on_server do 240 | sh! "docker run --rm -it " \ 241 | "--label app=#{app_name} " \ 242 | "--env-file #{project_root}/env_file " \ 243 | "-e GIT_REVISION=#{deployed_revision} " \ 244 | "-v #{project_root}/db:/rails/db/#{deploy_env} " \ 245 | "-v #{project_root}/storage:/rails/storage " \ 246 | "--entrypoint '/rails/bin/rails' " \ 247 | "#{deployed_image} " \ 248 | "console", interactive: true 249 | end 250 | end 251 | 252 | def logs_command 253 | # Determine which ports are currently bound and which are free for the new container 254 | running_container, *other_containers = on_server { sh! "docker ps -q --filter label=app=#{app_name}" }.output.split 255 | abort "Multiple containers found, cannot follow logs: #{other_containers.inspect}" unless other_containers.empty? 256 | 257 | log "Following container #{running_container} logs" 258 | 259 | on_server do 260 | sh "docker logs -f #{running_container}", interactive: true 261 | end 262 | end 263 | 264 | def sudo_command 265 | system "ssh root@#{server}", exception: true 266 | end 267 | 268 | def help_command 269 | puts <<~HELP 270 | Usage: hamal [command] 271 | 272 | Commands: 273 | deploy - Deploy the app to the server 274 | console - Run Rails console in the deployed container 275 | logs - Follow logs of the deployed container 276 | sudo - SSH into the server as administrator 277 | HELP 278 | end 279 | end 280 | end 281 | --------------------------------------------------------------------------------