diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..75405937 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.gitignore b/.gitignore index 5c514e8d..53d88f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ yarn-debug.log* /app/assets/builds/* !/app/assets/builds/.keep + +# Ignore all environment files. +/.env* diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 00000000..d7a99873 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,8 @@ +# Docker registry password +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Rails master key +RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# Database password +POSTGRES_PASSWORD=$POSTGRES_PASSWORD diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..2ee8e9d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t anycable_rails_demo . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name anycable_rails_demo anycable_rails_demo + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.3.0 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems and node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev node-gyp pkg-config python-is-python3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install JavaScript dependencies +ARG NODE_VERSION=22.5.1 +ARG YARN_VERSION=1.22.22 +ENV PATH=/usr/local/node/bin:$PATH +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + npm install -g yarn@$YARN_VERSION && \ + rm -rf /tmp/node-build-master + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Install node modules +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +RUN rm -rf node_modules + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index 965f85d5..ee28973c 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,9 @@ gem 'cssbundling-rails' gem 'jsbundling-rails' gem 'propshaft' +gem 'kamal', '~> 2.4', require: false +gem 'thruster', '~> 0.1.9', require: false + group :development, :test do gem 'debug', '1.7.0' gem 'rspec-rails', '~> 6.0' diff --git a/Gemfile.lock b/Gemfile.lock index 78e79f71..6b4cf0e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,6 +94,9 @@ GEM ruby-next-core (~> 1.0) ast (2.4.2) base64 (0.2.0) + bcrypt_pbkdf (1.1.1) + bcrypt_pbkdf (1.1.1-arm64-darwin) + bcrypt_pbkdf (1.1.1-x86_64-darwin) bigdecimal (3.1.7) bootsnap (1.18.3) msgpack (~> 1.2) @@ -121,7 +124,9 @@ GEM irb (>= 1.5.0) reline (>= 0.3.1) diff-lcs (1.5.1) + dotenv (3.1.7) drb (2.2.1) + ed25519 (1.3.0) erubi (1.12.0) ferrum (0.14) addressable (~> 2.5) @@ -164,6 +169,17 @@ GEM reline (>= 0.4.2) jsbundling-rails (1.3.0) railties (>= 6.0.0) + kamal (2.4.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.2) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -186,8 +202,13 @@ GEM net-protocol net-protocol (0.2.2) timeout + net-scp (4.0.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.0) net-protocol + net-ssh (7.3.0) nio4r (2.7.1) nokogiri (1.16.3-aarch64-linux) racc (~> 1.4) @@ -201,6 +222,7 @@ GEM racc (~> 1.4) nokogiri (1.16.3-x86_64-linux) racc (~> 1.4) + ostruct (0.6.1) paco (0.2.3) parser (3.3.0.5) ast (~> 2.4.1) @@ -291,9 +313,20 @@ GEM ruby-next-core (1.0.2) ruby-next-parser (3.2.2.0) parser (>= 3.0.3.1) + sshkit (1.23.2) + base64 + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct stringio (3.1.0) test-prof (1.3.2) thor (1.3.1) + thruster (0.1.9) + thruster (0.1.9-aarch64-linux) + thruster (0.1.9-arm64-darwin) + thruster (0.1.9-x86_64-darwin) + thruster (0.1.9-x86_64-linux) timeout (0.4.1) turbo-rails (2.0.5) actionpack (>= 6.0.0) @@ -310,7 +343,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.13) + zeitwerk (2.7.1) PLATFORMS aarch64-linux @@ -330,6 +363,7 @@ DEPENDENCIES debug (= 1.7.0) grpc (~> 1.37) jsbundling-rails + kamal (~> 2.4) nanoid pg (~> 1.0) propshaft @@ -339,6 +373,7 @@ DEPENDENCIES rspec-rails (~> 6.0) ruby-next (~> 1.0) test-prof + thruster (~> 0.1.9) turbo-rails RUBY VERSION diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 00000000..57567d69 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,14 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 00000000..36bde2d8 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 00000000..115ca296 --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +FQ6TLkZtOGf76esz8l5PLT7MXqapk57nSaOL7tmYQbI0xL7//Evm18OcEw47bJjNf9GLdSaBljbeF2d6hp2lSuQ2CJyjpxBlU2SXWI2upHYlE0hLfloqVSLhPfngJ6STa/Z/5TOWuROMKt2tviDbE/HBGhnmoJAufvVRL4LESHZHk0Yd68LBhqIqBpGDEDuzxVnOUbm9IlI9H1w4+VrCUsI6yrY9LvQW+mrMQ7VE12/UzJ50SxaxUveEFDxK1ArrJt8rLzLz1nqS5v9dz4Bhu+q8GmDzuy1q02IAwdEva9AOPa8GnJxkXuQdOTBIRFTwYOXK8RsJYEiKss9HuB34FUIGG+W/Z4PfDdztDG34cCBbSwTuFjLmJJmEOKnj6t34zr04VN9VzVSMW4M/Y0+D3+sOGlll--g6R/+zsAm12Rz45Q--juS76hv2QA8wUZqVwBAnOg== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index dfc41703..4b1acafd 100644 --- a/config/database.yml +++ b/config/database.yml @@ -14,4 +14,7 @@ test: production: <<: *default - adapter: postgresql + host: <%= ENV['DB_HOST'] %> + database: any_rails_demo_production + username: any_rails_demo + password: <%= ENV['POSTGRES_PASSWORD'] %> diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 00000000..45f496c5 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,82 @@ +# Name of your application. Used to uniquely configure containers. +service: anycable_rails_demo + +# Name of the container image. +image: pjpires10/anycable_rails_demo + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + + anycable-rpc: + hosts: + - 192.168.0.1 + cmd: bundle exec anycable + options: + network-alias: anycable_rails_demo-rpc + +proxy: + ssl: true + host: demo.anycable.io + +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + username: pjpires10 + + # Always use an access token rather than real password (pulled from .kamal/secrets). + password: + - KAMAL_REGISTRY_PASSWORD + +env: + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + clear: + DB_HOST: anycable_rails_demo-db + REDIS_URL: "redis://anycable_rails_demo-redis:6379" + ANYCABLE_RPC_HOST: "0.0.0.0:50051" + ANYCABLE_REDIS_URL: "redis://anycable_rails_demo-redis:6379/0" + ANYCABLE_WEBSOCKET_URL: "wss://ws.demo.anycable.io/cable" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +builder: + arch: amd64 + +accessories: + db: + image: postgres:16 + host: 192.168.0.1 + env: + clear: + POSTGRES_USER: any_rails_demo + POSTGRES_DB: any_rails_demo_production + secret: + - POSTGRES_PASSWORD + directories: + - data:/var/lib/postgresql/data + redis: + image: redis:7.0 + host: 192.168.0.1 + directories: + - data:/data + anycable-go: + image: anycable/anycable-go:1.5 + host: 192.168.0.1 + proxy: + host: ws.demo.anycable.io + ssl: true + app_port: 8080 + healthcheck: + path: /health + env: + clear: + ANYCABLE_HOST: "0.0.0.0" + ANYCABLE_PORT: 8080 + ANYCABLE_RPC_HOST: anycable_rails_demo-rpc:50051 + ANYCABLE_REDIS_URL: "redis://anycable_rails_demo-redis:6379/0" diff --git a/config/environments/production.rb b/config/environments/production.rb index fe3fb61f..fa450f2b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -20,9 +20,8 @@ # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). # config.require_master_key = true - # Disable serving static files from the `/public` folder by default since - # Apache or NGINX already handles this. - config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"} # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.action_controller.asset_host = 'http://assets.example.com' @@ -36,6 +35,9 @@ # config.action_cable.url = 'wss://example.com/cable' # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true diff --git a/config/routes.rb b/config/routes.rb index 9ad910bf..fac36590 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true Rails.application.routes.draw do + # App boot health check + get "/up", to: "rails/health#show", as: :rails_health_check + get "/login", to: "sessions#new", as: :login post "/login", to: "sessions#create" delete "/logout", to: "sessions#destroy", as: :logout diff --git a/dip.yml b/dip.yml index 6170972a..4b82ed5d 100644 --- a/dip.yml +++ b/dip.yml @@ -68,7 +68,7 @@ interaction: run_options: [service-ports, use-aliases] yarn: - descriptinn: Run yarn commands + description: Run yarn commands service: rails command: yarn @@ -82,6 +82,7 @@ provision: - '[[ "$RESET_DOCKER" == "true" ]] && echo "Re-creating the Docker env from scratch..." && dip compose down --volumes || echo "Re-provisioning the Docker env..."' - dip compose up -d postgres redis - dip bundle install + - dip yarn install - dip rails db:prepare - dip rails db:test:prepare - echo "🚀 Ready to rock! Run 'dip rails s' to start a Rails web server w/ AnyCable"