Skip to content

Commit cc195b3

Browse files
authored
Add deployment configuration for Kamal (#38)
1 parent 2692923 commit cc195b3

21 files changed

+538
-5
lines changed

.gitignore

+5-1
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@
2323
/public/assets
2424
.byebug_history
2525

26-
# Ignore master key for decrypting credentials and more.
26+
# Ignore master key for decrypting credentials and more
2727
/config/master.key
2828

2929
/app/assets/builds/*
3030
!/app/assets/builds/.keep
3131

3232
# Ignore environment variables
3333
.env
34+
35+
# Ignore deployment configuration and secrets
36+
/config/deploy.yml
37+
/.kamal/secrets

.kamal/hooks/docker-setup.sample

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env ruby
2+
3+
# A sample docker-setup hook
4+
#
5+
# Sets up a Docker network on defined hosts which can then be used by the application’s containers
6+
7+
hosts = ENV["KAMAL_HOSTS"].split(",")
8+
9+
hosts.each do |ip|
10+
destination = "root@#{ip}"
11+
puts "Creating a Docker network \"kamal\" on #{destination}"
12+
`ssh #{destination} docker network create kamal`
13+
end

.kamal/hooks/post-deploy.sample

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/sh
2+
3+
# A sample post-deploy hook
4+
#
5+
# These environment variables are available:
6+
# KAMAL_RECORDED_AT
7+
# KAMAL_PERFORMER
8+
# KAMAL_VERSION
9+
# KAMAL_HOSTS
10+
# KAMAL_ROLE (if set)
11+
# KAMAL_DESTINATION (if set)
12+
# KAMAL_RUNTIME
13+
14+
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

.kamal/hooks/post-proxy-reboot.sample

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"

.kamal/hooks/pre-build.sample

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/sh
2+
3+
# A sample pre-build hook
4+
#
5+
# Checks:
6+
# 1. We have a clean checkout
7+
# 2. A remote is configured
8+
# 3. The branch has been pushed to the remote
9+
# 4. The version we are deploying matches the remote
10+
#
11+
# These environment variables are available:
12+
# KAMAL_RECORDED_AT
13+
# KAMAL_PERFORMER
14+
# KAMAL_VERSION
15+
# KAMAL_HOSTS
16+
# KAMAL_ROLE (if set)
17+
# KAMAL_DESTINATION (if set)
18+
19+
if [ -n "$(git status --porcelain)" ]; then
20+
echo "Git checkout is not clean, aborting..." >&2
21+
git status --porcelain >&2
22+
exit 1
23+
fi
24+
25+
first_remote=$(git remote)
26+
27+
if [ -z "$first_remote" ]; then
28+
echo "No git remote set, aborting..." >&2
29+
exit 1
30+
fi
31+
32+
current_branch=$(git branch --show-current)
33+
34+
if [ -z "$current_branch" ]; then
35+
echo "Not on a git branch, aborting..." >&2
36+
exit 1
37+
fi
38+
39+
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
40+
41+
if [ -z "$remote_head" ]; then
42+
echo "Branch not pushed to remote, aborting..." >&2
43+
exit 1
44+
fi
45+
46+
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
47+
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
48+
exit 1
49+
fi
50+
51+
exit 0

.kamal/hooks/pre-connect.sample

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env ruby
2+
3+
# A sample pre-connect check
4+
#
5+
# Warms DNS before connecting to hosts in parallel
6+
#
7+
# These environment variables are available:
8+
# KAMAL_RECORDED_AT
9+
# KAMAL_PERFORMER
10+
# KAMAL_VERSION
11+
# KAMAL_HOSTS
12+
# KAMAL_ROLE (if set)
13+
# KAMAL_DESTINATION (if set)
14+
# KAMAL_RUNTIME
15+
16+
hosts = ENV["KAMAL_HOSTS"].split(",")
17+
results = nil
18+
max = 3
19+
20+
elapsed = Benchmark.realtime do
21+
results = hosts.map do |host|
22+
Thread.new do
23+
tries = 1
24+
25+
begin
26+
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
27+
rescue SocketError
28+
if tries < max
29+
puts "Retrying DNS warmup: #{host}"
30+
tries += 1
31+
sleep rand
32+
retry
33+
else
34+
puts "DNS warmup failed: #{host}"
35+
host
36+
end
37+
end
38+
39+
tries
40+
end
41+
end.map(&:value)
42+
end
43+
44+
retries = results.sum - hosts.size
45+
nopes = results.count { |r| r == max }
46+
47+
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

.kamal/hooks/pre-deploy.sample

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env ruby
2+
3+
# A sample pre-deploy hook
4+
#
5+
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
6+
#
7+
# Fails unless the combined status is "success"
8+
#
9+
# These environment variables are available:
10+
# KAMAL_RECORDED_AT
11+
# KAMAL_PERFORMER
12+
# KAMAL_VERSION
13+
# KAMAL_HOSTS
14+
# KAMAL_COMMAND
15+
# KAMAL_SUBCOMMAND
16+
# KAMAL_ROLE (if set)
17+
# KAMAL_DESTINATION (if set)
18+
19+
# Only check the build status for production deployments
20+
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
21+
exit 0
22+
end
23+
24+
require "bundler/inline"
25+
26+
# true = install gems so this is fast on repeat invocations
27+
gemfile(true, quiet: true) do
28+
source "https://rubygems.org"
29+
30+
gem "octokit"
31+
gem "faraday-retry"
32+
end
33+
34+
MAX_ATTEMPTS = 72
35+
ATTEMPTS_GAP = 10
36+
37+
def exit_with_error(message)
38+
$stderr.puts message
39+
exit 1
40+
end
41+
42+
class GithubStatusChecks
43+
attr_reader :remote_url, :git_sha, :github_client, :combined_status
44+
45+
def initialize
46+
@remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
47+
@git_sha = `git rev-parse HEAD`.strip
48+
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
49+
refresh!
50+
end
51+
52+
def refresh!
53+
@combined_status = github_client.combined_status(remote_url, git_sha)
54+
end
55+
56+
def state
57+
combined_status[:state]
58+
end
59+
60+
def first_status_url
61+
first_status = combined_status[:statuses].find { |status| status[:state] == state }
62+
first_status && first_status[:target_url]
63+
end
64+
65+
def complete_count
66+
combined_status[:statuses].count { |status| status[:state] != "pending"}
67+
end
68+
69+
def total_count
70+
combined_status[:statuses].count
71+
end
72+
73+
def current_status
74+
if total_count > 0
75+
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
76+
else
77+
"Build not started..."
78+
end
79+
end
80+
end
81+
82+
83+
$stdout.sync = true
84+
85+
puts "Checking build status..."
86+
attempts = 0
87+
checks = GithubStatusChecks.new
88+
89+
begin
90+
loop do
91+
case checks.state
92+
when "success"
93+
puts "Checks passed, see #{checks.first_status_url}"
94+
exit 0
95+
when "failure"
96+
exit_with_error "Checks failed, see #{checks.first_status_url}"
97+
when "pending"
98+
attempts += 1
99+
end
100+
101+
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
102+
103+
puts checks.current_status
104+
sleep(ATTEMPTS_GAP)
105+
checks.refresh!
106+
end
107+
rescue Octokit::NotFound
108+
exit_with_error "Build status could not be found"
109+
end

.kamal/hooks/pre-proxy-reboot.sample

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."

Dockerfile

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# syntax=docker/dockerfile:1
2+
# check=error=true
3+
4+
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
5+
# docker build -t railsflix .
6+
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name railsflix railsflix
7+
8+
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
9+
10+
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
11+
ARG RUBY_VERSION=3.3.6
12+
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
13+
14+
# Rails app lives here
15+
WORKDIR /rails
16+
17+
# Install base packages
18+
RUN apt-get update -qq && \
19+
apt-get install --no-install-recommends -y curl libjemalloc2 libvips cron && \
20+
rm -rf /var/lib/apt/lists /var/cache/apt/archives
21+
22+
RUN curl -o /etc/apt/trusted.gpg.d/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc && \
23+
echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main 16" > /etc/apt/sources.list.d/pgdg.list && \
24+
apt-get update -qq && \
25+
apt-get install --no-install-recommends -y postgresql-client-16
26+
27+
# Set production environment
28+
ENV RAILS_ENV="production" \
29+
BUNDLE_DEPLOYMENT="1" \
30+
BUNDLE_PATH="/usr/local/bundle" \
31+
BUNDLE_WITHOUT="development"
32+
33+
# Throw-away build stage to reduce size of final image
34+
FROM base AS build
35+
36+
# Install packages needed to build gems
37+
RUN apt-get update -qq && \
38+
apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev node-gyp python-is-python3 && \
39+
rm -rf /var/lib/apt/lists /var/cache/apt/archives
40+
41+
# Install JavaScript dependencies
42+
ARG NODE_VERSION=22.9.0
43+
ARG YARN_VERSION=1.22.22
44+
ENV PATH=/usr/local/node/bin:$PATH
45+
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
46+
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
47+
npm install -g yarn@$YARN_VERSION && \
48+
rm -rf /tmp/node-build-master
49+
50+
# Install application gems
51+
COPY Gemfile Gemfile.lock ./
52+
RUN bundle install && \
53+
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
54+
bundle exec bootsnap precompile --gemfile
55+
56+
# Install node modules
57+
COPY package.json yarn.lock ./
58+
RUN yarn install --frozen-lockfile
59+
60+
# Copy application code
61+
COPY . .
62+
63+
# Precompile bootsnap code for faster boot times
64+
RUN bundle exec bootsnap precompile app/ lib/
65+
66+
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
67+
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
68+
69+
RUN rm -rf node_modules
70+
71+
72+
# Final stage for app image
73+
FROM base
74+
75+
# Copy built artifacts: gems, application
76+
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
77+
COPY --from=build /rails /rails
78+
79+
# Run and own only the runtime files as a non-root user for security
80+
RUN groupadd --system --gid 1000 rails && \
81+
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
82+
chown -R rails:rails db log storage tmp && \
83+
chown rails:rails /etc/environment && \
84+
chmod gu+rw /var/run && \
85+
chmod gu+s /usr/sbin/cron
86+
USER 1000:1000
87+
88+
# Entrypoint prepares the database.
89+
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
90+
91+
# Start server via Thruster by default, this can be overwritten at runtime
92+
EXPOSE 80
93+
CMD ["./bin/thrust", "./bin/rails", "server"]

Gemfile

+6
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ gem "turbo-rails"
2828
# Reduces boot times through caching; required in config/boot.rb
2929
gem "bootsnap", require: false
3030

31+
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
32+
gem "kamal", ">= 2.0.0", require: false
33+
34+
# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/]
35+
gem "thruster", require: false
36+
3137
group :development, :test do
3238
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
3339
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

0 commit comments

Comments
 (0)