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=<value from config/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"