diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..4d4a9caeb --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Grab the registry password from ENV +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key or config/credentials/*.key into git! +RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# Either use .env or rails credentials to store database password. +# HOSTEDGPT_DATABASE_PASSWORD=$HOSTEDGPT_DATABASE_PASSWORD +credentials=$(bin/rails credentials:show --environment production) +HOSTEDGPT_DATABASE_PASSWORD=$(echo "$credentials" | yq '.database.password // "password"') + +# Used by postgres:16 image to set password +POSTGRES_PASSWORD=$HOSTEDGPT_DATABASE_PASSWORD diff --git a/Gemfile b/Gemfile index c60685a0e..56b76a579 100644 --- a/Gemfile +++ b/Gemfile @@ -75,6 +75,8 @@ group :development do gem "rubocop-capybara" gem "rubocop-minitest" gem "dockerfile-rails", ">= 1.6" + + gem "kamal", "~> 2.0" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index d1cf6690d..f29f023ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,9 @@ GEM aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) + bcrypt_pbkdf (1.1.1-arm64-darwin) + bcrypt_pbkdf (1.1.1-x86_64-darwin) bigdecimal (3.1.7) bindex (0.8.1) bootsnap (1.17.0) @@ -131,7 +134,9 @@ GEM reline (>= 0.3.1) dockerfile-rails (1.6.10) rails (>= 3.0.0) + dotenv (3.1.4) drb (2.2.1) + ed25519 (1.3.0) erubi (1.12.0) event_stream_parser (1.0.0) faraday (2.8.1) @@ -142,6 +147,10 @@ GEM multipart-post (~> 2) faraday-net_http (3.0.2) ffi (1.17.0) + ffi (1.17.0-aarch64-linux-musl) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86_64-darwin) + ffi (1.17.0-x86_64-linux-gnu) globalid (1.2.1) activesupport (>= 6.1) hashie (5.0.0) @@ -162,6 +171,17 @@ GEM json (2.7.1) jwt (2.8.1) base64 + kamal (2.2.2) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.2) + net-ssh (~> 7.0) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (~> 2.5) language_server-protocol (3.17.0.3) lint_roller (1.1.0) logger (1.6.1) @@ -196,8 +216,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.4.0.1) net-protocol + net-ssh (7.3.0) nio4r (2.7.0) nokogiri (1.16.3-aarch64-linux) racc (~> 1.4) @@ -229,6 +254,7 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + ostruct (0.6.0) parallel (1.24.0) parser (3.2.2.4) ast (~> 2.4.1) @@ -378,6 +404,12 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + sshkit (1.23.1) + base64 + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct standard (1.32.1) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -455,6 +487,7 @@ DEPENDENCIES dockerfile-rails (>= 1.6) image_processing (~> 1.13.0) importmap-rails + kamal (~> 2.0) minitest-stub_any_instance name_of_person omniauth (~> 2.1) diff --git a/README.md b/README.md index 78c3a5fbc..dac717368 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,17 @@ This project is led by an experienced rails developer, but I'm actively looking - [Troubleshooting Render](#troubleshooting-render) - [Deploy the app on Fly.io](#deploy-the-app-on-flyio) - [Deploy the app on Heroku](#deploy-the-app-on-heroku) +- [Deploy to own servers with Kamal2](#deploy-to-own-servers-with-kamal2) - [Deploy on your own server](#deploy-on-your-own-server) - [Configure optional features](#configure-optional-features) - - [Give assistant access to your Google apps](#configuring-google-tools) + - [Configuring Google Tools](#configuring-google-tools) - [Authentication](#authentication) - [Password authentication](#password-authentication) - [Google OAuth authentication](#google-oauth-authentication) - [HTTP header authentication](#http-header-authentication) - [Contribute as a developer](#contribute-as-a-developer) - - [Running locally](#Running-locally) - - [Alternatively, you can skip Docker:](#alternatively-you-can-set-skip-docker) + - [Running locally](#running-locally) + - [Alternatively, you can skip Docker](#alternatively-you-can-skip-docker) - [Running tests](#running-tests) - [Understanding the Docker configuration](#understanding-the-docker-configuration) - [Changelog](#changelog) @@ -111,6 +112,38 @@ Eligible students can apply for Heroku platform credits through [Heroku for GitH You may want to read about [configuring optional features](#configure-optional-features). +## Deploy to own servers with Kamal + +[Kamal](https://kamal-deploy.org/) offers zero-downtime deploys, rolling restarts, asset bridging, remote builds, accessory service management, and everything else you need to deploy and manage your web app in production with Docker. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized. + +First, create your production credentials file. + +```plain +bin/rails credentials:edit --environment production +``` + +Second, create a Docker Hub access token and store it as local env var `KAMAL_REGISTRY_PASSWORD`. + +Next, edit `config/deploy.yml`: + +1. Change `my-docker-user` to your Docker Hub username +2. Change `168.192.0.1` to the IP or hostname of your target Linux server +3. If you need to `ssh` into that server as anything other than `root` user, then uncomment `ssh:` section and edit your ssh username. +4. Change `hostedgpt.example.com` to the public CNAME or A record that points to your server IP address. + +Next, commit all the changes to git so Kamal picks them up. + +```plain +git add . +git commit -m "Add production credentials and Kamal config" +``` + +Now, run the command to setup the Postgres database, build HostedGPT using docker buildx, and deploy it to your server: + +```plain +kamal setup +``` + ## Deploy on your own server There are only two services that need to be running for this app to work: the Puma web server and a Postgres database. diff --git a/config/database.yml b/config/database.yml index 6fa635417..c4b6a118b 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,24 +1,27 @@ default: &default adapter: postgresql encoding: unicode - host: localhost pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - port: <%= ENV['HOSTEDGPT_DATABASE_PORT'] || 5432 %> -<% if RUBY_PLATFORM =~ /darwin/ %> + <% if ENV["HOSTEDGPT_DATABASE_HOST"] %> + host: <%= ENV["HOSTEDGPT_DATABASE_HOST"] %> + <% end %> + <% if ENV["HOSTEDGPT_DATABASE_PORT"] %> + port: <%= ENV["HOSTEDGPT_DATABASE_PORT"] %> + <% end %> + <% if RUBY_PLATFORM =~ /darwin/ %> gssencmode: disable -<% end %> + <% end %> development: <<: *default - database: <%= ENV['HOSTEDGPT_DEV_DB'] || "hostedgpt_development" %> + database: <%= ENV.fetch("HOSTEDGPT_DEV_DB", "hostedgpt_development") %> test: <<: *default - database: <%= ENV['HOSTEDGPT_TEST_DB'] || "hostedgpt_test" %> + database: <%= ENV.fetch("HOSTEDGPT_TEST_DB", "hostedgpt_test") %> production: <<: *default - database: hostedgpt_production - username: hostedgpt + database: <%= ENV.fetch("HOSTEDGPT_PRODUCTION_DB", "hostedgpt_production") %> + username: <%= ENV.fetch("HOSTEDGPT_DATABASE_USERNAME", "hostedgpt") %> password: <%= ENV["HOSTEDGPT_DATABASE_PASSWORD"] %> - \ No newline at end of file diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..abf806d17 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,107 @@ +# Name of your application. Used to uniquely configure containers. +service: hostedgpt + +# Name of the container image. +image: my-docker-user/hostedgpt + +# Deploy to these servers. +servers: + web: + - 168.192.0.1 + # job: + # hosts: + # - 168.192.0.1 + # cmd: bin/rake solid_queue:start + +# Enable SSL auto certification via Let's Encrypt (and allow for multiple apps on one server). +# Set ssl: false if using something like Cloudflare to terminate SSL (but keep host!). +proxy: + ssl: true + host: hostedgpt.example.com + app_port: 8080 + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + username: my-docker-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 + - HOSTEDGPT_DATABASE_PASSWORD + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + RUN_SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + HOSTEDGPT_FORCE_SSL: "false" + + # Match this to any external database server to configure Active Record correctly + HOSTEDGPT_DATABASE_HOST: hostedgpt-db + HOSTEDGPT_DATABASE_USERNAME: postgres + HOSTEDGPT_PRODUCTION_DB: hostedgpt_production + + # Log everything from Rails + RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +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" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "hostedgpt_storage:/rails/storage" + +# 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 + +# Configure the image builder. +builder: + arch: amd64 + +# # Build image via remote server (useful for faster amd64 builds on arm64 computers) +# remote: ssh://docker@docker-builder-server +# +# # Pass arguments and secrets to the Docker build process +# args: +# RUBY_VERSION: ruby-3.3.5 +# secrets: +# - GITHUB_TOKEN +# - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: deploy + +# Use accessory services (secrets come from .kamal/secrets). +accessories: + db: + image: postgres:16 + host: 168.192.0.1 + # port: 5432 + env: + clear: + POSTGRES_DB: hostedgpt_production + secret: + - POSTGRES_PASSWORD + directories: + - data:/var/lib/postgresql/data diff --git a/config/environments/production.rb b/config/environments/production.rb index 820191930..ae9557180 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -48,7 +48,7 @@ # config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + config.force_ssl = ENV["HOSTEDGPT_FORCE_SSL"] != "false" # Log to STDOUT by default config.logger = ActiveSupport::Logger.new($stdout) diff --git a/lib/templates/rails/credentials/credentials.yml.tt b/lib/templates/rails/credentials/credentials.yml.tt new file mode 100644 index 000000000..8b1716b48 --- /dev/null +++ b/lib/templates/rails/credentials/credentials.yml.tt @@ -0,0 +1,10 @@ +# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. +secret_key_base: <%= secret_key_base %> + +active_record_encryption: + primary_key: <%= SecureRandom.alphanumeric(32) %> + deterministic_key: <%= SecureRandom.alphanumeric(32) %> + key_derivation_salt: <%= SecureRandom.alphanumeric(32) %> + +database: + password: <%= SecureRandom.alphanumeric(32) %>