{
  "@context": "https://schema.org",
  "@id": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-05-hot-cell-v1-0-securing-active-storage-in-the-age-of-ai/#webpage",
  "@type": [
    "WebPage",
    "ArchiveComponent"
  ],
  "articleBody": "Today we are releasing [Hot Cell](https://github.com/basecamp/hotcell) v1.0, a suite of gems that moves Active Storage’s attachment processing out of your Rails application and into an unprivileged sidecar container with no network, no credentials, and nothing on its filesystem worth stealing. Adopting it is a configuration change, not a code change. It is already running in production at 37signals, in Basecamp, HEY, and Fizzy.\n\nI introduced Hot Cell at Rails World 2026 in a talk titled “Hot Cell: Securing Active Storage in the age of AI.” What follows is basically that talk, written down, plus a couple of things that have changed since then. (If you’d rather [watch the video](https://www.youtube.com/watch?v=swXl8M84YmM&list=PLdMRFKO1zSBE&index=28) or [flip through the slides](https://mike.daless.io/prez/2026-09-23_rails-world-hotcell/slides.html), go for it.)\n\n* * *\n\n## Where we are\n\nIn the beginning, the earth cooled. Dinosaurs roamed the earth. Then humans started writing software, and a lot of that software was very trusting in nature: corner cases weren’t explored, and the design assumed users were friendly and input could be trusted.\n\nNow we find ourselves in a very chaotic moment where AI is very good at finding these bugs and security problems. I don’t know what’s coming, but at this moment I’m very worried, and I think you should be too. So first I’m going to scare you, and then I’m going to give you the tools to do something about it.\n\n* * *\n\n## Part 1: You are not worried enough (probably)\n\nMy summer started with [CVE-2026-66066](https://discuss.rubyonrails.org/t/cve-2026-66066-possible-arbitrary-file-read-and-remote-code-execution-in-active-storage-variant-processing/91432), nicknamed “KindaRails2Shell.” I’m a member of the Rails security team, and I happened to catch this report and ended up working on it. We scored it 9.5 on CVSS, which is about as bad as it gets. Here’s the description we published:\n\n> In versions prior to 7.2.3.2, 8.0.5.1 and 8.1.3.1, Active Storage does not disable libvips operations marked unsafe for untrusted content, allowing a crafted upload to invoke such an operation. Consuming applications are affected when configured to use libvips and accept image uploads from untrusted users. An unauthenticated attacker may exploit this behavior to read arbitrary files accessible to the Rails process, including environment variables and application secrets. Exposure of credentials such as secret\\_key\\_base or external-service tokens may enable remote code execution or lateral movement.\n\nI wrote this CVE description to be intentionally opaque. We didn’t disclose any details about the attack, because we didn’t want attackers to start exploiting live apps before anyone had a chance to upgrade. We were trying to buy you some time. Unfortunately, AI has gotten so good that multiple researchers were still able to derive this attack within hours of the announcement, quickly making the embargo meaningless.\n\nYou can watch the talk for a more in-depth explanation, but the root of the problem is that the image processing libraries are running in a container with access to your secrets and to your network.\n\nAnd there was no single big vulnerability here that AI agents could find. The attack requires chaining together several low-severity vulnerabilities along with some Rails design decisions that helped enable the attack.\n\n1. **Active Storage direct uploads were enabled by default**, so the route was open even in apps that didn’t use them. Niklas Häusele fixed that in [rails/rails#58369](https://github.com/rails/rails/pull/58369).\n2. **Direct uploads never examine the bytes.** The file goes from the browser to the blob store without passing through Rails, so Rails trusts the reported content type. Lying about the content type is the way in. Fixing this would cost performance.\n3. **Signed variation keys do not include the blob ID**, which makes the attack easier to pull off. It’s not a security problem on its own, and changing it would be a breaking change.\n4. **Rails didn’t call `Vips.block_untrusted`.** libvips added it in 8.13 as an opt-in, buried in a changelog. Calling it is the fix for this CVE.\n5. **libvips was fooled by a spoofed MATLAB header.** This is the actual flaw. It’s fixed in libvips 8.18.5, and no CVE was published.\n6. **libmatio trusts every file it is handed**, as designed. The maintainers pointed back at libvips: you shouldn’t be handing untrusted files to libmatio. Won’t fix.\n7. **Known gadgets turn `secret_key_base` into RCE.** The Rails security team knows about these, but closing them is a breaking change.\n\nAgents are able to chain all seven together from very little information. They are getting very good at this! In the talk I showed a recording of the exploit script pulling every secret out of a development app in real time. Once you know how to execute the attack, it happens very fast.\n\nIt should also scare you that 378 vulnerabilities were published in libvips and its dependencies in the first nine months of 2026, and more are reported every day.\n\nThis chart is 37signals’ HackerOne data: valid reports received each month. There has been a steady increase since models turned the corner back in November 2025.\n\nSo there’s a whole category of problems here. I can fix this one CVE, but we’ll have to go through all of this again when someone finds a zero-day in the *next* image processing library. Hardening those libraries is not under our control, and it’s not something we can fix within Rails.\n\nWhen I think about addressing the bigger issues here, I think about risk:\n\nThe probability of another vulnerability in these libraries is very high, and it’s not going down any time soon. That leaves impact. How do we shrink the blast radius, so that the next vulnerability does as little damage as possible?\n\n* * *\n\n## Part 2: Reducing the blast radius\n\nThe question I want to answer is: **How can we run Active Storage so that it doesn’t matter if the libraries are vulnerable or if the input is maliciously crafted?**\n\nYou’re probably thinking “sandbox!” and so was I. But I wanted some very specific attributes in this sandbox.\n\n### Step 1: Define the requirements\n\nThis list became the prompt I gave my agent as we built the system together:\n\n- **Implement existing Rails behavior.** A drop-in replacement for Active Storage’s analyzers, transformers, and previewers. A configuration change, not a code change.\n- **Least privilege.** No capabilities, no privilege escalation, no `setuid`, an unprivileged user.\n- **Hard limits.** Wall-clock deadline, memory, file size, open files, and process count.\n- **No access to the app filesystem.** No app code, no credentials. The filesystem is read-only and `noexec`.\n- **No access to the app environment.** No secrets to exfiltrate.\n- **No network interface.** No calling home, and no lateral movement.\n- **Disposable.** A process per request, killed and reaped when it’s done, so remote code execution has a short lifetime.\n- **Familiar lifecycle.** Deployable with Kamal or Kubernetes.\n- **Extensible behavior.** If you want a secure place to unzip files, you should be able to build that too.\n\n### Step 2: An obscure, too-clever name 🤣\n\n*Image: a frame from an [Oak Ridge National Laboratory video](https://www.youtube.com/watch?v=FC4k4bB_KGQ).*\n\nThis is a hot cell at Oak Ridge National Laboratory. It’s a shielded chamber for handling radioactive material: a big lead box with a window that radiation can’t pass through. The operator stays outside and does everything with manipulator arms, and nothing enters or leaves except by controlled transfer.\n\nThat’s exactly what I want for Active Storage.\n\n### Step 3: Jam it into Rails\n\nThankfully, Rails already has configuration for this. Analyzers extract metadata, like the number of pages in a PDF. The variant processor does transformations: rotating, cropping, resizing. Previewers generate thumbnails for files that aren’t images, like PDFs and videos. Here are the defaults, trimmed to the ones we care about:\n\n```ruby\n# config/application.rb\nconfig.active_storage.variant_processor = :vips\nconfig.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer::Vips,\n                                    ActiveStorage::Analyzer::VideoAnalyzer ]\nconfig.active_storage.previewers = [ ActiveStorage::Previewer::MuPDFPreviewer ]\n```\n\nAnd here’s the target API, with no application code changes:\n\n```ruby\n# config/application.rb\nconfig.active_storage.variant_processor = ActiveStorage::HotCell::Client::Transformers::Image::Vips\nconfig.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Image::Vips,\n                                    ActiveStorage::HotCell::Client::Analyzers::Video::FFprobe ]\nconfig.active_storage.previewers = [ ActiveStorage::HotCell::Client::Previewers::Pdf::Mutool ]\n```\n\nOne caveat: passing a class as the `variant_processor` became possible with [rails/rails#58384](https://github.com/rails/rails/pull/58384), so the Active Storage gems need Rails 8.2.\n\nHot Cell is a family of gems. `hotcell-core` holds the common bits: the wire protocol, descriptor passing, and the error codes. `hotcell-client` runs in your app and `hotcell-server` runs in the cell. The `activestorage-hotcell-client` and `activestorage-hotcell-server` gems subclass those to provide the drop-in replacements for Rails. Since the talk, a sixth gem has joined them: `yabeda-hotcell`, which I’ll get to below.\n\n### Step 4: Inputs and outputs\n\nI’ve hand-waved over how the client and the server communicate, given that the cell has no network interface.\n\nThe answer is UNIX sockets. A UNIX socket is a file on disk that two processes on the same host can talk over, and nothing about it is routable on a network. A cell exposes two of them in a shared directory: one for work, and one for metadata and control.\n\nAnd the magic bit is that `sendmsg()` can pass open file descriptors over a UNIX socket. The Hot Cell client in your app opens the upload and the output file, and hands those descriptors to the cell. The cell reads and writes through them but never sees a path, and has no access to the rest of your filesystem. Inputs are read-only and outputs are write-only, and the kernel enforces it. Passing descriptors also [closes off the class of attacks](https://basecamp.github.io/hotcell/docs/design/descriptors/) that use symbolic links to traverse paths.\n\n### Step 5: Doing the work\n\nThis part is a supervisor and workers, like Puma or Solid Queue. The [request lifecycle](https://basecamp.github.io/hotcell/docs/request-lifecycle/) walks through each step.\n\nThe supervisor is pid 1 in the cell. It accepts connections, queues them, hands each to a worker, enforces the wall-clock deadline, kills the process group, and reaps. It never reads a request and never evaluates a byte of image data.\n\nEach request is handled by a forked worker. A successful attack only compromises that worker, which gets reaped.\n\nThat’s the finished system, and it’s what is running in production at 37signals today. Now that I’ve scared you, I’m telling you that you should be running Hot Cell in your apps, and here’s how.\n\n* * *\n\n## Part 3: Rolling it out\n\nAdd `activestorage-hotcell-client` to your app’s `Gemfile`, and [run the installer](https://basecamp.github.io/hotcell/#1-install):\n\n```console\n$ bin/rails hotcell:install\n$ find hotcell\nhotcell/Dockerfile\nhotcell/Gemfile\nhotcell/config.rb\nhotcell/operations/\nhotcell/operations/.keep\n```\n\nEverything about the cell lives in that `hotcell/` directory. The cell has its own `Gemfile`, which you should keep short, because every gem in it is inside the blast radius. `config.rb` is plain Ruby, not Rails, and sets the [cell’s limits](https://basecamp.github.io/hotcell/docs/cell-settings/#request-limits). Each operation’s own limits are clamped to these:\n\n```ruby\n# hotcell/Gemfile\nsource \"https://rubygems.org\"\n\ngem \"hotcell-server\", \"~> 1.0\"\ngem \"activestorage-hotcell-server\", \"~> 1.0\"\n```\n\n```ruby\n# hotcell/config.rb\nHotCell.limits concurrency: 4,\n               queue_size: 8,\n               queue_wait: 10,\n               deadline: 120,\n               memory: 1536 * 1024**2,\n               file_size: 256 * 1024**2\n```\n\nThe only thing the app and the cell share is a volume holding the two sockets. In the app’s Kamal config, you join the cell’s group and mount the volume:\n\n```yaml\n# config/deploy.yml -- the app\nservers:\n  web:\n    options:\n      group-add: 10001\n\nvolumes:\n  - hotcell-sockets:/run/hotcell/active_storage\n\nenv:\n  clear:\n    HOTCELL_ROOT: /run/hotcell\n    HOTCELL_GROUP: 10001\n```\n\nThe cell is a Kamal accessory, because Kamal hard-codes the network for roles and `network: none` is the point:\n\n```yaml\n# config/deploy.yml -- the cell\naccessories:\n  active_storage:\n    image: your.registry.com/your-image:latest\n    roles: [ web, jobs ]\n    network: none\n    volumes:\n      - hotcell-sockets:/run/hotcell/cell\n    options:\n      cpus: 2\n      memory: 2g\n      memory-swap: 2g\n      pids-limit: 512\n      read-only: true\n      cap-drop: ALL\n      security-opt: no-new-privileges:true\n      user: 10001:10001\n      tmpfs: /tmp:rw,nosuid,nodev,noexec,size=512m\n    env:\n      clear:\n        HOTCELL_DIR: /run/hotcell/cell\n```\n\nThe volume matches the app’s. The resource limits cap CPU, memory (with swap pinned equal so the limit holds), and processes, so a fork bomb dies in the cell. And every one of `network: none`, `read-only`, `cap-drop`, and `no-new-privileges` is a [security property](https://basecamp.github.io/hotcell/docs/container/#security-flags). If you omit one, the protection is gone and the cell keeps serving requests exactly as before.\n\nThen pick the [Hot Cell twin](https://basecamp.github.io/hotcell/docs/active-storage/#classes) of each Active Storage class you use:\n\n| `ActiveStorage`                        | `ActiveStorage::HotCell::Client` |\n| -------------------------------------- | -------------------------------- |\n| `Transformers::Vips`                   | `Transformers::Image::Vips`      |\n| `Transformers::ImageMagick`            | `Transformers::Image::Magick`    |\n| `Analyzer::ImageAnalyzer::Vips`        | `Analyzers::Image::Vips`         |\n| `Analyzer::ImageAnalyzer::ImageMagick` | `Analyzers::Image::Magick`       |\n| `Analyzer::VideoAnalyzer`              | `Analyzers::Video::FFprobe`      |\n| `Analyzer::AudioAnalyzer`              | `Analyzers::Audio::FFprobe`      |\n| `Previewer::MuPDFPreviewer`            | `Previewers::Pdf::Mutool`        |\n| `Previewer::PopplerPDFPreviewer`       | `Previewers::Pdf::Poppler`       |\n| `Previewer::VideoPreviewer`            | `Previewers::Video::FFmpeg`      |\n\nSet the three Active Storage configs as above, and point Hot Cell at the sockets:\n\n```ruby\n# config/initializers/hotcell.rb\nHotCell.root  = ENV[\"HOTCELL_ROOT\"]\nHotCell.group = ENV[\"HOTCELL_GROUP\"]\n```\n\nOn the cell side, require the operations that match. The cell isn’t a Rails app, so there’s no Zeitwerk; requiring a file is what serves its operation. This is also where you [set per-operation limits](https://basecamp.github.io/hotcell/docs/operation-api/#change-a-shipped-operations-limits):\n\n```ruby\n# hotcell/operations/active_storage.rb\nrequire \"active_storage/hot_cell/server/transformers/image/vips\"\nrequire \"active_storage/hot_cell/server/analyzers/image/vips\"\nrequire \"active_storage/hot_cell/server/analyzers/media/ffprobe\"\nrequire \"active_storage/hot_cell/server/previewers/pdf/mutool\"\nrequire \"active_storage/hot_cell/server/previewers/video/ffmpeg\"\n\nActiveStorage::HotCell::Server::Transformers::Image::Vips\n  .limits file_size: 256 * 1024**2\n\nActiveStorage::HotCell::Server::Previewers::Pdf::Mutool\n  .limits deadline: 30\n```\n\nThat’s all it takes to port a Rails app that’s using vanilla Active Storage!\n\n1. Configure Active Storage.\n2. Configure the cell’s `Gemfile` and `config.rb`.\n3. Extend the Kamal (or Kubernetes) config.\n\nOnce a file type is handled by the cell, consider [removing its packages](https://basecamp.github.io/hotcell/docs/active-storage/#remove-packages-from-the-application-image) (libvips, ffmpeg, and so on) from your application image to reduce the attack surface.\n\nThe [Hot Cell documentation](https://basecamp.github.io/hotcell/) covers all of this, including [every container flag](https://basecamp.github.io/hotcell/docs/container/#container-flags) and [how to size the limits](https://basecamp.github.io/hotcell/docs/tuning/).\n\n### Observability\n\nWe can’t trust the workers, since any one of them may have been compromised. But we can trust the supervisor, and we can trust your Rails app, and between them they can give us fantastic visibility into image processing:\n\n1. **[Events in the app](https://basecamp.github.io/hotcell/docs/observability/#per-call-notification).** Every call publishes a `perform.hot_cell` Active Support notification with the operation, outcome, cause, bytes in and out, and timing. A dead cell shows up here too, as `unavailable`.\n2. **[Metrics from the supervisor](https://basecamp.github.io/hotcell/docs/observability/#cell-metrics).** The app asks the cell’s control socket for metrics like queue depth, running workers, and kills by cause.\n3. **[Logs from the supervisor](https://basecamp.github.io/hotcell/docs/observability/#cell-log).** One JSON object per event on stdout, so you can see when a worker was killed and why.\n\nIn the talk, wiring those into your app was left as an exercise using our examples. In v1.0 they ship in the gems:\n\n- `HotCell::LogSubscriber`, in `hotcell-client`, writes one line to the Rails log for every call.\n- The `yabeda-hotcell` gem records [Yabeda](https://github.com/yabeda-rb/yabeda) metrics for every call, plus gauges scraped from each cell’s control socket. Add the gem and call `Yabeda::HotCell.install!`.\n- `HotCell::HealthController` and `HotCell::DiagnosticsController`, in `hotcell-client`, give you a public health check and an authenticated diagnostic endpoint that does a real round trip through the work socket.\n\nThe Hot Cell docs list the [alerts we recommend](https://basecamp.github.io/hotcell/docs/observability/#recommended-alerts).\n\nThis is the dashboard we use for Basecamp.\n\n### The cost\n\nIn the talk, I said Hot Cell costs about 8ms per call in our environment. Since then, we’ve brought that down: **in Basecamp’s production environment, the overhead is now about 3.4ms per call**.\n\nThat’s a cheap price to know you won’t be owned the next time a zero-day is found in an image library.\n\n### Coloring outside the box\n\nHot Cell isn’t only for Active Storage:\n\n- You can run multiple cells per host, which gives you multiple queues with different depths, timeouts, and deadlines.\n- An operation can take multiple input and output files.\n- You can bring your own container. A [conformance test](https://basecamp.github.io/hotcell/docs/conformance/) tells you whether it’s configured properly.\n- Every operation’s limits are configurable.\n\nA [custom operation](https://basecamp.github.io/hotcell/docs/operation-api/) is shaped like an Active Job. It lives in `hotcell/operations/`, has a routing name and its own default limits, and does its work in `perform`:\n\n```ruby\n# hotcell/operations/extract_text.rb\nclass ExtractTextOperation < HotCell::Operation\n  operation \"extract_text\"\n  limits deadline: 30.seconds, memory: 1280.megabytes\n\n  def perform(inputs, outputs, format:, pages: [])\n    source, = inputs\n    destination, = outputs\n\n    complicated_image_manipulation(source, destination, format:, pages:)\n  end\nend\n```\n\nIn the app, a [thin client class](https://basecamp.github.io/hotcell/docs/client-api/#write-a-client) names the cell and the operation. Where a job has `perform_later`, a client has `perform_in_hotcell`, a blocking call that serializes the arguments, passes the descriptors, and returns the operation’s result:\n\n```ruby\nclass ExtractText < HotCell::Client\n  hotcell \"documents\"\n  operation \"extract_text\"\nend\n\nFile.open(pdf_path, \"rb\") do |source|\n  File.open(text_path, \"wb\") do |destination|\n    @result = ExtractText.perform_in_hotcell(source, destination, format: \"txt\", pages: [1, 2])\n  end\nend\n```\n\nOpening the files yourself is the only hoop to jump through, and usually the class you write hides it.\n\n### What production taught us\n\nHEY and Fizzy are relatively modern apps that use vanilla Active Storage, and porting them was no sweat.\n\nBasecamp was harder. Basecamp predates Active Storage (Active Storage was extracted from it), and a lot of its attachment code never moved over, so I had to write custom operations. Basecamp has 12 of them in its cell so far. Basecamp also uses attachments heavily; for some reason our customers love sending each other memes.\n\nWe had outages along the way, and I rolled the lessons back into the library:\n\n- **The OpenMP thread pool.** ImageMagick, and the libraries libvips delegates to, size their OpenMP thread pools from the host’s core count, not the container’s `cpus` quota. On a 98-core production host that’s 98 threads at 8MB of stack each, which blew through the worker’s memory limit, and 285 workers died. The fix is to [set `OMP_NUM_THREADS` and `OMP_THREAD_LIMIT`](https://basecamp.github.io/hotcell/docs/container/#bound-the-openmp-thread-pools) in the image, forwarded to every tool the cell runs.\n- **The scratch disk filled.** ImageMagick wrote its pixel cache to `/tmp`, outside the per-request directory, and cleans it up only on a clean exit. Every killed worker left its cache behind, until the 4GB scratch filled on six hosts. While it was full, 3,393 files were marked permanently unreadable. The fixes were to point `TMPDIR` and `MAGICK_TMPDIR` at the request’s directory ([hotcell#51](https://github.com/basecamp/hotcell/pull/51)), empty the scratch at boot ([hotcell#52](https://github.com/basecamp/hotcell/pull/52)), and [size ImageMagick’s limits](https://basecamp.github.io/hotcell/docs/imagemagick/#set-the-limits) to a worker’s share of the scratch.\n\nAllocate some time for tuning. Size `file_size` and the deadlines from what your real uploads take, then watch `killed` by cause. The [tuning guide](https://basecamp.github.io/hotcell/docs/tuning/) covers how.\n\n### What’s next\n\nAt Rails World I said Hot Cell wasn’t 1.0 yet because we were waiting for Rails 8.2, and because I wanted to ship more of the observability features. The observability features are now in the gems.\n\nI’m still interested in [Linux Landlock](https://github.com/basecamp/hotcell/issues/13). Landlock lets a process ratchet down its own permissions so that it can never regain them, even if an attacker takes it over. That’s a nice belt-and-suspenders addition, and if you know Landlock, I’d love to talk.\n\n* * *\n\n## Part 4: The moment we are in\n\nIs this AI’s fault? Well, it’s easy to assign blame, and you’d be forgiven for doing so. AI chained several low-severity flaws into a critical-severity attack, and it made an effective embargo impossible.\n\nBut I want to suggest a more nuanced view. I’m seeing things trend in a good direction, and I want to be optimistic. I don’t want Terminator, I want Star Trek. The alternative is that we all quit our jobs, turn off our computers, and become sheep farmers (I guess!?).\n\nThat HackerOne chart I showed you to scare you is the system working as designed. People are finding vulnerabilities and reporting them responsibly, which is exactly what we want. It’s frustrating to deal with, but there are a finite number of vulnerabilities, and the number has to come back down at some point.\n\nAnd when I said 378 vulnerabilities were found this year, what I should have said is that 378 vulnerabilities were **fixed**. Responsible maintainers are fixing the problems and shipping updates, and those libraries are trending in the right direction.\n\nHere’s what you can do to help us get through this faster:\n\n- **Vulnerability reports should come with their own agent skills.** After the CVE, I published [rails/rails-forensics-CVE-2026-66066](https://github.com/rails/rails-forensics-CVE-2026-66066), extracted from my investigation of our own apps at 37signals. Point your agent at it and at your application, and ask whether you were vulnerable (probably) and whether you were exploited. It will go through your Active Storage records and tell you whether you need to rotate your secrets.\n- **Attack your own work.** While building Hot Cell, I ran an adversarial review on almost every commit, and Jeremy helped me red-team it by setting agents loose to break in. They found real gaps. That doesn’t make Hot Cell perfect, but it has fewer problems than it would have.\n- **Improve or replace existing systems.** The only way through this period is for software to get better, or to be replaced. Hot Cell, baby!\n\nDon’t be too scared. Be proactive and constructive, and the future will be Star Trek, not Terminator.\n\nLive long and prosper, and go try [Hot Cell](https://github.com/basecamp/hotcell).\n",
  "authors": [
    "Mike Dalessio"
  ],
  "captured_at": "2026-10-06T00:45:55.302474734+00:00",
  "category": "programming",
  "content_html": "<p>Today we are releasing <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/hotcell\">Hot Cell</a> v1.0, a suite of gems that moves Active Storage’s attachment processing out of your Rails application and into an unprivileged sidecar container with no network, no credentials, and nothing on its filesystem worth stealing. Adopting it is a configuration change, not a code change. It is already running in production at 37signals, in Basecamp, HEY, and Fizzy.</p>\n<p>I introduced Hot Cell at Rails World 2026 in a talk titled “Hot Cell: Securing Active Storage in the age of AI.” What follows is basically that talk, written down, plus a couple of things that have changed since then. (If you’d rather <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://www.youtube.com/watch?v=swXl8M84YmM&amp;list=PLdMRFKO1zSBE&amp;index=28\">watch the video</a> or <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://mike.daless.io/prez/2026-09-23_rails-world-hotcell/slides.html\">flip through the slides</a>, go for it.)</p>\n<hr />\n<h2>Where we are</h2>\n<p>In the beginning, the earth cooled. Dinosaurs roamed the earth. Then humans started writing software, and a lot of that software was very trusting in nature: corner cases weren’t explored, and the design assumed users were friendly and input could be trusted.</p>\n<p>Now we find ourselves in a very chaotic moment where AI is very good at finding these bugs and security problems. I don’t know what’s coming, but at this moment I’m very worried, and I think you should be too. So first I’m going to scare you, and then I’m going to give you the tools to do something about it.</p>\n<hr />\n<h2>Part 1: You are not worried enough (probably)</h2>\n<p>My summer started with <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://discuss.rubyonrails.org/t/cve-2026-66066-possible-arbitrary-file-read-and-remote-code-execution-in-active-storage-variant-processing/91432\">CVE-2026-66066</a>, nicknamed “KindaRails2Shell.” I’m a member of the Rails security team, and I happened to catch this report and ended up working on it. We scored it 9.5 on CVSS, which is about as bad as it gets. Here’s the description we published:</p>\n<blockquote>\n<p>In versions prior to 7.2.3.2, 8.0.5.1 and 8.1.3.1, Active Storage does not disable libvips operations marked unsafe for untrusted content, allowing a crafted upload to invoke such an operation. Consuming applications are affected when configured to use libvips and accept image uploads from untrusted users. An unauthenticated attacker may exploit this behavior to read arbitrary files accessible to the Rails process, including environment variables and application secrets. Exposure of credentials such as secret_key_base or external-service tokens may enable remote code execution or lateral movement.</p>\n</blockquote>\n<p>I wrote this CVE description to be intentionally opaque. We didn’t disclose any details about the attack, because we didn’t want attackers to start exploiting live apps before anyone had a chance to upgrade. We were trying to buy you some time. Unfortunately, AI has gotten so good that multiple researchers were still able to derive this attack within hours of the announcement, quickly making the embargo meaningless.</p>\n<p>You can watch the talk for a more in-depth explanation, but the root of the problem is that the image processing libraries are running in a container with access to your secrets and to your network.</p>\n<p>And there was no single big vulnerability here that AI agents could find. The attack requires chaining together several low-severity vulnerabilities along with some Rails design decisions that helped enable the attack.</p>\n<ol>\n<li><strong>Active Storage direct uploads were enabled by default</strong>, so the route was open even in apps that didn’t use them. Niklas Häusele fixed that in <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/rails/rails/pull/58369\">rails/rails#58369</a>.</li>\n<li><strong>Direct uploads never examine the bytes.</strong> The file goes from the browser to the blob store without passing through Rails, so Rails trusts the reported content type. Lying about the content type is the way in. Fixing this would cost performance.</li>\n<li><strong>Signed variation keys do not include the blob ID</strong>, which makes the attack easier to pull off. It’s not a security problem on its own, and changing it would be a breaking change.</li>\n<li><strong>Rails didn’t call <code>Vips.block_untrusted</code>.</strong> libvips added it in 8.13 as an opt-in, buried in a changelog. Calling it is the fix for this CVE.</li>\n<li><strong>libvips was fooled by a spoofed MATLAB header.</strong> This is the actual flaw. It’s fixed in libvips 8.18.5, and no CVE was published.</li>\n<li><strong>libmatio trusts every file it is handed</strong>, as designed. The maintainers pointed back at libvips: you shouldn’t be handing untrusted files to libmatio. Won’t fix.</li>\n<li><strong>Known gadgets turn <code>secret_key_base</code> into RCE.</strong> The Rails security team knows about these, but closing them is a breaking change.</li>\n</ol>\n<p>Agents are able to chain all seven together from very little information. They are getting very good at this! In the talk I showed a recording of the exploit script pulling every secret out of a development app in real time. Once you know how to execute the attack, it happens very fast.</p>\n<p>It should also scare you that 378 vulnerabilities were published in libvips and its dependencies in the first nine months of 2026, and more are reported every day.</p>\n<p>This chart is 37signals’ HackerOne data: valid reports received each month. There has been a steady increase since models turned the corner back in November 2025.</p>\n<p>So there’s a whole category of problems here. I can fix this one CVE, but we’ll have to go through all of this again when someone finds a zero-day in the <em>next</em> image processing library. Hardening those libraries is not under our control, and it’s not something we can fix within Rails.</p>\n<p>When I think about addressing the bigger issues here, I think about risk:</p>\n<p>The probability of another vulnerability in these libraries is very high, and it’s not going down any time soon. That leaves impact. How do we shrink the blast radius, so that the next vulnerability does as little damage as possible?</p>\n<hr />\n<h2>Part 2: Reducing the blast radius</h2>\n<p>The question I want to answer is: <strong>How can we run Active Storage so that it doesn’t matter if the libraries are vulnerable or if the input is maliciously crafted?</strong></p>\n<p>You’re probably thinking “sandbox!” and so was I. But I wanted some very specific attributes in this sandbox.</p>\n<h3>Step 1: Define the requirements</h3>\n<p>This list became the prompt I gave my agent as we built the system together:</p>\n<ul>\n<li><strong>Implement existing Rails behavior.</strong> A drop-in replacement for Active Storage’s analyzers, transformers, and previewers. A configuration change, not a code change.</li>\n<li><strong>Least privilege.</strong> No capabilities, no privilege escalation, no <code>setuid</code>, an unprivileged user.</li>\n<li><strong>Hard limits.</strong> Wall-clock deadline, memory, file size, open files, and process count.</li>\n<li><strong>No access to the app filesystem.</strong> No app code, no credentials. The filesystem is read-only and <code>noexec</code>.</li>\n<li><strong>No access to the app environment.</strong> No secrets to exfiltrate.</li>\n<li><strong>No network interface.</strong> No calling home, and no lateral movement.</li>\n<li><strong>Disposable.</strong> A process per request, killed and reaped when it’s done, so remote code execution has a short lifetime.</li>\n<li><strong>Familiar lifecycle.</strong> Deployable with Kamal or Kubernetes.</li>\n<li><strong>Extensible behavior.</strong> If you want a secure place to unzip files, you should be able to build that too.</li>\n</ul>\n<h3>Step 2: An obscure, too-clever name 🤣</h3>\n<p><em>Image: a frame from an <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://www.youtube.com/watch?v=FC4k4bB_KGQ\">Oak Ridge National Laboratory video</a>.</em></p>\n<p>This is a hot cell at Oak Ridge National Laboratory. It’s a shielded chamber for handling radioactive material: a big lead box with a window that radiation can’t pass through. The operator stays outside and does everything with manipulator arms, and nothing enters or leaves except by controlled transfer.</p>\n<p>That’s exactly what I want for Active Storage.</p>\n<h3>Step 3: Jam it into Rails</h3>\n<p>Thankfully, Rails already has configuration for this. Analyzers extract metadata, like the number of pages in a PDF. The variant processor does transformations: rotating, cropping, resizing. Previewers generate thumbnails for files that aren’t images, like PDFs and videos. Here are the defaults, trimmed to the ones we care about:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> config/application.rb\n</span>config<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>active_storage<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>variant_processor <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span>vips</span>\nconfig<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>active_storage<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>analyzers <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span> <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Analyzer</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">ImageAnalyzer</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Vips<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n                                    <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Analyzer</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>VideoAnalyzer <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span>\nconfig<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>active_storage<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>previewers <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span> <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Previewer</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>MuPDFPreviewer <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span>\n</span></span></code></pre>\n<p>And here’s the target API, with no application code changes:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> config/application.rb\n</span>config<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>active_storage<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>variant_processor <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Client</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Transformers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Image</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Vips\nconfig<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>active_storage<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>analyzers <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span> <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Client</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Analyzers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Image</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Vips<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n                                    <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Client</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Analyzers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Video</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>FFprobe <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span>\nconfig<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>active_storage<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>previewers <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span> <span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Client</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Previewers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Pdf</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Mutool <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span>\n</span></span></code></pre>\n<p>One caveat: passing a class as the <code>variant_processor</code> became possible with <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/rails/rails/pull/58384\">rails/rails#58384</a>, so the Active Storage gems need Rails 8.2.</p>\n<p>Hot Cell is a family of gems. <code>hotcell-core</code> holds the common bits: the wire protocol, descriptor passing, and the error codes. <code>hotcell-client</code> runs in your app and <code>hotcell-server</code> runs in the cell. The <code>activestorage-hotcell-client</code> and <code>activestorage-hotcell-server</code> gems subclass those to provide the drop-in replacements for Rails. Since the talk, a sixth gem has joined them: <code>yabeda-hotcell</code>, which I’ll get to below.</p>\n<h3>Step 4: Inputs and outputs</h3>\n<p>I’ve hand-waved over how the client and the server communicate, given that the cell has no network interface.</p>\n<p>The answer is UNIX sockets. A UNIX socket is a file on disk that two processes on the same host can talk over, and nothing about it is routable on a network. A cell exposes two of them in a shared directory: one for work, and one for metadata and control.</p>\n<p>And the magic bit is that <code>sendmsg()</code> can pass open file descriptors over a UNIX socket. The Hot Cell client in your app opens the upload and the output file, and hands those descriptors to the cell. The cell reads and writes through them but never sees a path, and has no access to the rest of your filesystem. Inputs are read-only and outputs are write-only, and the kernel enforces it. Passing descriptors also <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/design/descriptors/\">closes off the class of attacks</a> that use symbolic links to traverse paths.</p>\n<h3>Step 5: Doing the work</h3>\n<p>This part is a supervisor and workers, like Puma or Solid Queue. The <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/request-lifecycle/\">request lifecycle</a> walks through each step.</p>\n<p>The supervisor is pid 1 in the cell. It accepts connections, queues them, hands each to a worker, enforces the wall-clock deadline, kills the process group, and reaps. It never reads a request and never evaluates a byte of image data.</p>\n<p>Each request is handled by a forked worker. A successful attack only compromises that worker, which gets reaped.</p>\n<p>That’s the finished system, and it’s what is running in production at 37signals today. Now that I’ve scared you, I’m telling you that you should be running Hot Cell in your apps, and here’s how.</p>\n<hr />\n<h2>Part 3: Rolling it out</h2>\n<p>Add <code>activestorage-hotcell-client</code> to your app’s <code>Gemfile</code>, and <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/#1-install\">run the installer</a>:</p>\n<pre><code class=\"language-console\"><span class=\"code-snippet\" data-language=\"Shell\"><span class=\"syntax-source syntax-shell syntax-bash\"><span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">$</span></span><span class=\"syntax-meta syntax-function-call syntax-arguments syntax-shell\"> bin/rails hotcell:install</span>\n<span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">$</span></span><span class=\"syntax-meta syntax-function-call syntax-arguments syntax-shell\"> find hotcell</span>\n<span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">hotcell/Dockerfile</span></span>\n<span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">hotcell/Gemfile</span></span>\n<span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">hotcell/config.rb</span></span>\n<span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">hotcell/operations/</span></span>\n<span class=\"syntax-meta syntax-function-call syntax-shell\"><span class=\"syntax-variable syntax-function syntax-shell\">hotcell/operations/.keep</span></span>\n</span></span></code></pre>\n<p>Everything about the cell lives in that <code>hotcell/</code> directory. The cell has its own <code>Gemfile</code>, which you should keep short, because every gem in it is inside the blast radius. <code>config.rb</code> is plain Ruby, not Rails, and sets the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/cell-settings/#request-limits\">cell’s limits</a>. Each operation’s own limits are clamped to these:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> hotcell/Gemfile\n</span>source <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>https://rubygems.org<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>\n\n<span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">gem</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>hotcell-server<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>~&gt; 1.0<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n<span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">gem</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>activestorage-hotcell-server<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>~&gt; 1.0<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n</span></span></code></pre>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> hotcell/config.rb\n</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>limits <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">concurrency<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">4</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n               <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">queue_size<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">8</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n               <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">queue_wait<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">10</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n               <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">deadline<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">120</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n               <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">memory<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1536</span> <span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">*</span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1024</span><span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">**</span><span class=\"syntax-constant syntax-numeric syntax-ruby\">2</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n               <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">file_size<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">256</span> <span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">*</span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1024</span><span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">**</span><span class=\"syntax-constant syntax-numeric syntax-ruby\">2</span>\n</span></span></code></pre>\n<p>The only thing the app and the cell share is a volume holding the two sockets. In the app’s Kamal config, you join the cell’s group and mount the volume:</p>\n<pre><code class=\"language-yaml\"><span class=\"code-snippet\" data-language=\"YAML\"><span class=\"syntax-source syntax-yaml\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-line syntax-number-sign syntax-yaml\">#</span> config/deploy.yml -- the app\n</span><span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">servers</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">web</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">options</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">group-add</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-numeric syntax-integer syntax-yaml\">10001</span>\n\n<span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">volumes</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n  <span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">hotcell-sockets:/run/hotcell/active_storage</span>\n\n<span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">env</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">clear</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">HOTCELL_ROOT</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">/run/hotcell</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">HOTCELL_GROUP</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-numeric syntax-integer syntax-yaml\">10001</span>\n</span></span></code></pre>\n<p>The cell is a Kamal accessory, because Kamal hard-codes the network for roles and <code>network: none</code> is the point:</p>\n<pre><code class=\"language-yaml\"><span class=\"code-snippet\" data-language=\"YAML\"><span class=\"syntax-source syntax-yaml\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-line syntax-number-sign syntax-yaml\">#</span> config/deploy.yml -- the cell\n</span><span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">accessories</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">active_storage</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">image</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">your.registry.com/your-image:latest</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">roles</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-meta syntax-flow-sequence syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-sequence syntax-begin syntax-yaml\">[</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-in syntax-yaml\">web</span><span class=\"syntax-punctuation syntax-separator syntax-sequence syntax-yaml\">,</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-in syntax-yaml\">jobs</span> <span class=\"syntax-punctuation syntax-definition syntax-sequence syntax-end syntax-yaml\">]</span></span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">network</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">none</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">volumes</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n      <span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">hotcell-sockets:/run/hotcell/cell</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">options</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">cpus</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-numeric syntax-integer syntax-yaml\">2</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">memory</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">2g</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">memory-swap</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">2g</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">pids-limit</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-numeric syntax-integer syntax-yaml\">512</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">read-only</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-language syntax-boolean syntax-yaml\">true</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">cap-drop</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">ALL</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">security-opt</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">no-new-privileges:true</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">user</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">10001:10001</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">tmpfs</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">/tmp:rw,nosuid,nodev,noexec,size=512m</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">env</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n      <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">clear</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">HOTCELL_DIR</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">/run/hotcell/cell</span>\n</span></span></code></pre>\n<p>The volume matches the app’s. The resource limits cap CPU, memory (with swap pinned equal so the limit holds), and processes, so a fork bomb dies in the cell. And every one of <code>network: none</code>, <code>read-only</code>, <code>cap-drop</code>, and <code>no-new-privileges</code> is a <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/container/#security-flags\">security property</a>. If you omit one, the protection is gone and the cell keeps serving requests exactly as before.</p>\n<p>Then pick the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/active-storage/#classes\">Hot Cell twin</a> of each Active Storage class you use:</p>\n<table>\n<thead>\n<tr>\n<th><code>ActiveStorage</code></th>\n<th><code>ActiveStorage::HotCell::Client</code></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>Transformers::Vips</code></td>\n<td><code>Transformers::Image::Vips</code></td>\n</tr>\n<tr>\n<td><code>Transformers::ImageMagick</code></td>\n<td><code>Transformers::Image::Magick</code></td>\n</tr>\n<tr>\n<td><code>Analyzer::ImageAnalyzer::Vips</code></td>\n<td><code>Analyzers::Image::Vips</code></td>\n</tr>\n<tr>\n<td><code>Analyzer::ImageAnalyzer::ImageMagick</code></td>\n<td><code>Analyzers::Image::Magick</code></td>\n</tr>\n<tr>\n<td><code>Analyzer::VideoAnalyzer</code></td>\n<td><code>Analyzers::Video::FFprobe</code></td>\n</tr>\n<tr>\n<td><code>Analyzer::AudioAnalyzer</code></td>\n<td><code>Analyzers::Audio::FFprobe</code></td>\n</tr>\n<tr>\n<td><code>Previewer::MuPDFPreviewer</code></td>\n<td><code>Previewers::Pdf::Mutool</code></td>\n</tr>\n<tr>\n<td><code>Previewer::PopplerPDFPreviewer</code></td>\n<td><code>Previewers::Pdf::Poppler</code></td>\n</tr>\n<tr>\n<td><code>Previewer::VideoPreviewer</code></td>\n<td><code>Previewers::Video::FFmpeg</code></td>\n</tr>\n</tbody>\n</table>\n<p>Set the three Active Storage configs as above, and point Hot Cell at the sockets:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> config/initializers/hotcell.rb\n</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>root  <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-meta syntax-environment-variable syntax-ruby\"><span class=\"syntax-variable syntax-other syntax-constant syntax-ruby\">ENV</span>[<span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>HOTCELL_ROOT<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>]</span>\n<span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>group <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-meta syntax-environment-variable syntax-ruby\"><span class=\"syntax-variable syntax-other syntax-constant syntax-ruby\">ENV</span>[<span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>HOTCELL_GROUP<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>]</span>\n</span></span></code></pre>\n<p>On the cell side, require the operations that match. The cell isn’t a Rails app, so there’s no Zeitwerk; requiring a file is what serves its operation. This is also where you <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/operation-api/#change-a-shipped-operations-limits\">set per-operation limits</a>:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> hotcell/operations/active_storage.rb\n</span><span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">require</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>active_storage/hot_cell/server/transformers/image/vips<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n<span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">require</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>active_storage/hot_cell/server/analyzers/image/vips<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n<span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">require</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>active_storage/hot_cell/server/analyzers/media/ffprobe<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n<span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">require</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>active_storage/hot_cell/server/previewers/pdf/mutool<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n<span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">require</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>active_storage/hot_cell/server/previewers/video/ffmpeg<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n\n<span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Server</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Transformers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Image</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Vips\n  <span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>limits <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">file_size<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">256</span> <span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">*</span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1024</span><span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">**</span><span class=\"syntax-constant syntax-numeric syntax-ruby\">2</span>\n\n<span class=\"syntax-support syntax-class syntax-ruby\">ActiveStorage</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Server</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Previewers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">Pdf</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Mutool\n  <span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>limits <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">deadline<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">30</span>\n</span></span></code></pre>\n<p>That’s all it takes to port a Rails app that’s using vanilla Active Storage!</p>\n<ol>\n<li>Configure Active Storage.</li>\n<li>Configure the cell’s <code>Gemfile</code> and <code>config.rb</code>.</li>\n<li>Extend the Kamal (or Kubernetes) config.</li>\n</ol>\n<p>Once a file type is handled by the cell, consider <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/active-storage/#remove-packages-from-the-application-image\">removing its packages</a> (libvips, ffmpeg, and so on) from your application image to reduce the attack surface.</p>\n<p>The <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/\">Hot Cell documentation</a> covers all of this, including <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/container/#container-flags\">every container flag</a> and <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/tuning/\">how to size the limits</a>.</p>\n<h3>Observability</h3>\n<p>We can’t trust the workers, since any one of them may have been compromised. But we can trust the supervisor, and we can trust your Rails app, and between them they can give us fantastic visibility into image processing:</p>\n<ol>\n<li><strong><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/observability/#per-call-notification\">Events in the app</a>.</strong> Every call publishes a <code>perform.hot_cell</code> Active Support notification with the operation, outcome, cause, bytes in and out, and timing. A dead cell shows up here too, as <code>unavailable</code>.</li>\n<li><strong><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/observability/#cell-metrics\">Metrics from the supervisor</a>.</strong> The app asks the cell’s control socket for metrics like queue depth, running workers, and kills by cause.</li>\n<li><strong><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/observability/#cell-log\">Logs from the supervisor</a>.</strong> One JSON object per event on stdout, so you can see when a worker was killed and why.</li>\n</ol>\n<p>In the talk, wiring those into your app was left as an exercise using our examples. In v1.0 they ship in the gems:</p>\n<ul>\n<li><code>HotCell::LogSubscriber</code>, in <code>hotcell-client</code>, writes one line to the Rails log for every call.</li>\n<li>The <code>yabeda-hotcell</code> gem records <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/yabeda-rb/yabeda\">Yabeda</a> metrics for every call, plus gauges scraped from each cell’s control socket. Add the gem and call <code>Yabeda::HotCell.install!</code>.</li>\n<li><code>HotCell::HealthController</code> and <code>HotCell::DiagnosticsController</code>, in <code>hotcell-client</code>, give you a public health check and an authenticated diagnostic endpoint that does a real round trip through the work socket.</li>\n</ul>\n<p>The Hot Cell docs list the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/observability/#recommended-alerts\">alerts we recommend</a>.</p>\n<p>This is the dashboard we use for Basecamp.</p>\n<h3>The cost</h3>\n<p>In the talk, I said Hot Cell costs about 8ms per call in our environment. Since then, we’ve brought that down: <strong>in Basecamp’s production environment, the overhead is now about 3.4ms per call</strong>.</p>\n<p>That’s a cheap price to know you won’t be owned the next time a zero-day is found in an image library.</p>\n<h3>Coloring outside the box</h3>\n<p>Hot Cell isn’t only for Active Storage:</p>\n<ul>\n<li>You can run multiple cells per host, which gives you multiple queues with different depths, timeouts, and deadlines.</li>\n<li>An operation can take multiple input and output files.</li>\n<li>You can bring your own container. A <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/conformance/\">conformance test</a> tells you whether it’s configured properly.</li>\n<li>Every operation’s limits are configurable.</li>\n</ul>\n<p>A <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/operation-api/\">custom operation</a> is shaped like an Active Job. It lives in <code>hotcell/operations/</code>, has a routing name and its own default limits, and does its work in <code>perform</code>:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-comment syntax-line syntax-number-sign syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-comment syntax-ruby\">#</span> hotcell/operations/extract_text.rb\n</span><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-class syntax-ruby\">class</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> </span><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-entity syntax-name syntax-class syntax-ruby\">ExtractTextOperation</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> <span class=\"syntax-punctuation syntax-separator syntax-inheritance syntax-ruby\">&lt;</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> </span><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-entity syntax-other syntax-inherited-class syntax-ruby\"><span class=\"syntax-support syntax-other syntax-namespace syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Operation</span></span>\n  operation <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>extract_text<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>\n  limits <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">deadline<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">30</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>seconds<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">memory<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1280</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>megabytes\n\n  <span class=\"syntax-meta syntax-function syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-def syntax-ruby\">def</span></span><span class=\"syntax-meta syntax-function syntax-ruby\"> <span class=\"syntax-entity syntax-name syntax-function syntax-ruby\">perform</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">inputs</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-variable syntax-parameter syntax-ruby\">outputs</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-variable syntax-parameter syntax-ruby\">format</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-default-value syntax-ruby\"><span class=\"syntax-punctuation syntax-separator syntax-ruby\">:</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-variable syntax-parameter syntax-ruby\">pages</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-default-value syntax-ruby\"><span class=\"syntax-punctuation syntax-separator syntax-ruby\">:</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-default-value syntax-ruby\"> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span><span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span></span>\n    source<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> inputs\n    destination<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> outputs\n\n    complicated_image_manipulation<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>source<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> destination<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">format<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">pages<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span>\n  <span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n<span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n</span></span></code></pre>\n<p>In the app, a <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/client-api/#write-a-client\">thin client class</a> names the cell and the operation. Where a job has <code>perform_later</code>, a client has <code>perform_in_hotcell</code>, a blocking call that serializes the arguments, passes the descriptors, and returns the operation’s result:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-class syntax-ruby\">class</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> </span><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-entity syntax-name syntax-class syntax-ruby\">ExtractText</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> <span class=\"syntax-punctuation syntax-separator syntax-inheritance syntax-ruby\">&lt;</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> </span><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-entity syntax-other syntax-inherited-class syntax-ruby\"><span class=\"syntax-support syntax-other syntax-namespace syntax-ruby\">HotCell</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Client</span></span>\n  hotcell <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>documents<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>\n  operation <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>extract_text<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>\n<span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n\n<span class=\"syntax-support syntax-class syntax-ruby\">File</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span><span class=\"syntax-support syntax-function syntax-builtin syntax-ruby\">open</span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>pdf_path<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>rb<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span> <span class=\"syntax-keyword syntax-control syntax-start-block syntax-ruby\">do</span> <span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-begin syntax-ruby\">|</span></span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">source</span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-end syntax-ruby\">|</span></span></span>\n  <span class=\"syntax-support syntax-class syntax-ruby\">File</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span><span class=\"syntax-support syntax-function syntax-builtin syntax-ruby\">open</span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>text_path<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>wb<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span> <span class=\"syntax-keyword syntax-control syntax-start-block syntax-ruby\">do</span> <span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-begin syntax-ruby\">|</span></span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">destination</span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-end syntax-ruby\">|</span></span></span>\n    <span class=\"syntax-variable syntax-other syntax-readwrite syntax-instance syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-variable syntax-ruby\">@</span>result</span> <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-support syntax-class syntax-ruby\">ExtractText</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>perform_in_hotcell<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>source<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> destination<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">format<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>txt<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-other syntax-symbol syntax-ruby\">pages<span class=\"syntax-punctuation syntax-definition syntax-constant syntax-ruby\">:</span></span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span><span class=\"syntax-constant syntax-numeric syntax-ruby\">1</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">2</span><span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span>\n  <span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n<span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n</span></span></code></pre>\n<p>Opening the files yourself is the only hoop to jump through, and usually the class you write hides it.</p>\n<h3>What production taught us</h3>\n<p>HEY and Fizzy are relatively modern apps that use vanilla Active Storage, and porting them was no sweat.</p>\n<p>Basecamp was harder. Basecamp predates Active Storage (Active Storage was extracted from it), and a lot of its attachment code never moved over, so I had to write custom operations. Basecamp has 12 of them in its cell so far. Basecamp also uses attachments heavily; for some reason our customers love sending each other memes.</p>\n<p>We had outages along the way, and I rolled the lessons back into the library:</p>\n<ul>\n<li><strong>The OpenMP thread pool.</strong> ImageMagick, and the libraries libvips delegates to, size their OpenMP thread pools from the host’s core count, not the container’s <code>cpus</code> quota. On a 98-core production host that’s 98 threads at 8MB of stack each, which blew through the worker’s memory limit, and 285 workers died. The fix is to <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/container/#bound-the-openmp-thread-pools\">set <code>OMP_NUM_THREADS</code> and <code>OMP_THREAD_LIMIT</code></a> in the image, forwarded to every tool the cell runs.</li>\n<li><strong>The scratch disk filled.</strong> ImageMagick wrote its pixel cache to <code>/tmp</code>, outside the per-request directory, and cleans it up only on a clean exit. Every killed worker left its cache behind, until the 4GB scratch filled on six hosts. While it was full, 3,393 files were marked permanently unreadable. The fixes were to point <code>TMPDIR</code> and <code>MAGICK_TMPDIR</code> at the request’s directory (<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/hotcell/pull/51\">hotcell#51</a>), empty the scratch at boot (<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/hotcell/pull/52\">hotcell#52</a>), and <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/imagemagick/#set-the-limits\">size ImageMagick’s limits</a> to a worker’s share of the scratch.</li>\n</ul>\n<p>Allocate some time for tuning. Size <code>file_size</code> and the deadlines from what your real uploads take, then watch <code>killed</code> by cause. The <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.github.io/hotcell/docs/tuning/\">tuning guide</a> covers how.</p>\n<h3>What’s next</h3>\n<p>At Rails World I said Hot Cell wasn’t 1.0 yet because we were waiting for Rails 8.2, and because I wanted to ship more of the observability features. The observability features are now in the gems.</p>\n<p>I’m still interested in <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/hotcell/issues/13\">Linux Landlock</a>. Landlock lets a process ratchet down its own permissions so that it can never regain them, even if an attacker takes it over. That’s a nice belt-and-suspenders addition, and if you know Landlock, I’d love to talk.</p>\n<hr />\n<h2>Part 4: The moment we are in</h2>\n<p>Is this AI’s fault? Well, it’s easy to assign blame, and you’d be forgiven for doing so. AI chained several low-severity flaws into a critical-severity attack, and it made an effective embargo impossible.</p>\n<p>But I want to suggest a more nuanced view. I’m seeing things trend in a good direction, and I want to be optimistic. I don’t want Terminator, I want Star Trek. The alternative is that we all quit our jobs, turn off our computers, and become sheep farmers (I guess!?).</p>\n<p>That HackerOne chart I showed you to scare you is the system working as designed. People are finding vulnerabilities and reporting them responsibly, which is exactly what we want. It’s frustrating to deal with, but there are a finite number of vulnerabilities, and the number has to come back down at some point.</p>\n<p>And when I said 378 vulnerabilities were found this year, what I should have said is that 378 vulnerabilities were <strong>fixed</strong>. Responsible maintainers are fixing the problems and shipping updates, and those libraries are trending in the right direction.</p>\n<p>Here’s what you can do to help us get through this faster:</p>\n<ul>\n<li><strong>Vulnerability reports should come with their own agent skills.</strong> After the CVE, I published <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/rails/rails-forensics-CVE-2026-66066\">rails/rails-forensics-CVE-2026-66066</a>, extracted from my investigation of our own apps at 37signals. Point your agent at it and at your application, and ask whether you were vulnerable (probably) and whether you were exploited. It will go through your Active Storage records and tell you whether you need to rotate your secrets.</li>\n<li><strong>Attack your own work.</strong> While building Hot Cell, I ran an adversarial review on almost every commit, and Jeremy helped me red-team it by setting agents loose to break in. They found real gaps. That doesn’t make Hot Cell perfect, but it has fewer problems than it would have.</li>\n<li><strong>Improve or replace existing systems.</strong> The only way through this period is for software to get better, or to be replaced. Hot Cell, baby!</li>\n</ul>\n<p>Don’t be too scared. Be proactive and constructive, and the future will be Star Trek, not Terminator.</p>\n<p>Live long and prosper, and go try <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/hotcell\">Hot Cell</a>.</p>\n",
  "content_markdown": "Today we are releasing [Hot Cell](https://github.com/basecamp/hotcell) v1.0, a suite of gems that moves Active Storage’s attachment processing out of your Rails application and into an unprivileged sidecar container with no network, no credentials, and nothing on its filesystem worth stealing. Adopting it is a configuration change, not a code change. It is already running in production at 37signals, in Basecamp, HEY, and Fizzy.\n\nI introduced Hot Cell at Rails World 2026 in a talk titled “Hot Cell: Securing Active Storage in the age of AI.” What follows is basically that talk, written down, plus a couple of things that have changed since then. (If you’d rather [watch the video](https://www.youtube.com/watch?v=swXl8M84YmM&list=PLdMRFKO1zSBE&index=28) or [flip through the slides](https://mike.daless.io/prez/2026-09-23_rails-world-hotcell/slides.html), go for it.)\n\n* * *\n\n## Where we are\n\nIn the beginning, the earth cooled. Dinosaurs roamed the earth. Then humans started writing software, and a lot of that software was very trusting in nature: corner cases weren’t explored, and the design assumed users were friendly and input could be trusted.\n\nNow we find ourselves in a very chaotic moment where AI is very good at finding these bugs and security problems. I don’t know what’s coming, but at this moment I’m very worried, and I think you should be too. So first I’m going to scare you, and then I’m going to give you the tools to do something about it.\n\n* * *\n\n## Part 1: You are not worried enough (probably)\n\nMy summer started with [CVE-2026-66066](https://discuss.rubyonrails.org/t/cve-2026-66066-possible-arbitrary-file-read-and-remote-code-execution-in-active-storage-variant-processing/91432), nicknamed “KindaRails2Shell.” I’m a member of the Rails security team, and I happened to catch this report and ended up working on it. We scored it 9.5 on CVSS, which is about as bad as it gets. Here’s the description we published:\n\n> In versions prior to 7.2.3.2, 8.0.5.1 and 8.1.3.1, Active Storage does not disable libvips operations marked unsafe for untrusted content, allowing a crafted upload to invoke such an operation. Consuming applications are affected when configured to use libvips and accept image uploads from untrusted users. An unauthenticated attacker may exploit this behavior to read arbitrary files accessible to the Rails process, including environment variables and application secrets. Exposure of credentials such as secret\\_key\\_base or external-service tokens may enable remote code execution or lateral movement.\n\nI wrote this CVE description to be intentionally opaque. We didn’t disclose any details about the attack, because we didn’t want attackers to start exploiting live apps before anyone had a chance to upgrade. We were trying to buy you some time. Unfortunately, AI has gotten so good that multiple researchers were still able to derive this attack within hours of the announcement, quickly making the embargo meaningless.\n\nYou can watch the talk for a more in-depth explanation, but the root of the problem is that the image processing libraries are running in a container with access to your secrets and to your network.\n\nAnd there was no single big vulnerability here that AI agents could find. The attack requires chaining together several low-severity vulnerabilities along with some Rails design decisions that helped enable the attack.\n\n1. **Active Storage direct uploads were enabled by default**, so the route was open even in apps that didn’t use them. Niklas Häusele fixed that in [rails/rails#58369](https://github.com/rails/rails/pull/58369).\n2. **Direct uploads never examine the bytes.** The file goes from the browser to the blob store without passing through Rails, so Rails trusts the reported content type. Lying about the content type is the way in. Fixing this would cost performance.\n3. **Signed variation keys do not include the blob ID**, which makes the attack easier to pull off. It’s not a security problem on its own, and changing it would be a breaking change.\n4. **Rails didn’t call `Vips.block_untrusted`.** libvips added it in 8.13 as an opt-in, buried in a changelog. Calling it is the fix for this CVE.\n5. **libvips was fooled by a spoofed MATLAB header.** This is the actual flaw. It’s fixed in libvips 8.18.5, and no CVE was published.\n6. **libmatio trusts every file it is handed**, as designed. The maintainers pointed back at libvips: you shouldn’t be handing untrusted files to libmatio. Won’t fix.\n7. **Known gadgets turn `secret_key_base` into RCE.** The Rails security team knows about these, but closing them is a breaking change.\n\nAgents are able to chain all seven together from very little information. They are getting very good at this! In the talk I showed a recording of the exploit script pulling every secret out of a development app in real time. Once you know how to execute the attack, it happens very fast.\n\nIt should also scare you that 378 vulnerabilities were published in libvips and its dependencies in the first nine months of 2026, and more are reported every day.\n\nThis chart is 37signals’ HackerOne data: valid reports received each month. There has been a steady increase since models turned the corner back in November 2025.\n\nSo there’s a whole category of problems here. I can fix this one CVE, but we’ll have to go through all of this again when someone finds a zero-day in the *next* image processing library. Hardening those libraries is not under our control, and it’s not something we can fix within Rails.\n\nWhen I think about addressing the bigger issues here, I think about risk:\n\nThe probability of another vulnerability in these libraries is very high, and it’s not going down any time soon. That leaves impact. How do we shrink the blast radius, so that the next vulnerability does as little damage as possible?\n\n* * *\n\n## Part 2: Reducing the blast radius\n\nThe question I want to answer is: **How can we run Active Storage so that it doesn’t matter if the libraries are vulnerable or if the input is maliciously crafted?**\n\nYou’re probably thinking “sandbox!” and so was I. But I wanted some very specific attributes in this sandbox.\n\n### Step 1: Define the requirements\n\nThis list became the prompt I gave my agent as we built the system together:\n\n- **Implement existing Rails behavior.** A drop-in replacement for Active Storage’s analyzers, transformers, and previewers. A configuration change, not a code change.\n- **Least privilege.** No capabilities, no privilege escalation, no `setuid`, an unprivileged user.\n- **Hard limits.** Wall-clock deadline, memory, file size, open files, and process count.\n- **No access to the app filesystem.** No app code, no credentials. The filesystem is read-only and `noexec`.\n- **No access to the app environment.** No secrets to exfiltrate.\n- **No network interface.** No calling home, and no lateral movement.\n- **Disposable.** A process per request, killed and reaped when it’s done, so remote code execution has a short lifetime.\n- **Familiar lifecycle.** Deployable with Kamal or Kubernetes.\n- **Extensible behavior.** If you want a secure place to unzip files, you should be able to build that too.\n\n### Step 2: An obscure, too-clever name 🤣\n\n*Image: a frame from an [Oak Ridge National Laboratory video](https://www.youtube.com/watch?v=FC4k4bB_KGQ).*\n\nThis is a hot cell at Oak Ridge National Laboratory. It’s a shielded chamber for handling radioactive material: a big lead box with a window that radiation can’t pass through. The operator stays outside and does everything with manipulator arms, and nothing enters or leaves except by controlled transfer.\n\nThat’s exactly what I want for Active Storage.\n\n### Step 3: Jam it into Rails\n\nThankfully, Rails already has configuration for this. Analyzers extract metadata, like the number of pages in a PDF. The variant processor does transformations: rotating, cropping, resizing. Previewers generate thumbnails for files that aren’t images, like PDFs and videos. Here are the defaults, trimmed to the ones we care about:\n\n```ruby\n# config/application.rb\nconfig.active_storage.variant_processor = :vips\nconfig.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer::Vips,\n                                    ActiveStorage::Analyzer::VideoAnalyzer ]\nconfig.active_storage.previewers = [ ActiveStorage::Previewer::MuPDFPreviewer ]\n```\n\nAnd here’s the target API, with no application code changes:\n\n```ruby\n# config/application.rb\nconfig.active_storage.variant_processor = ActiveStorage::HotCell::Client::Transformers::Image::Vips\nconfig.active_storage.analyzers = [ ActiveStorage::HotCell::Client::Analyzers::Image::Vips,\n                                    ActiveStorage::HotCell::Client::Analyzers::Video::FFprobe ]\nconfig.active_storage.previewers = [ ActiveStorage::HotCell::Client::Previewers::Pdf::Mutool ]\n```\n\nOne caveat: passing a class as the `variant_processor` became possible with [rails/rails#58384](https://github.com/rails/rails/pull/58384), so the Active Storage gems need Rails 8.2.\n\nHot Cell is a family of gems. `hotcell-core` holds the common bits: the wire protocol, descriptor passing, and the error codes. `hotcell-client` runs in your app and `hotcell-server` runs in the cell. The `activestorage-hotcell-client` and `activestorage-hotcell-server` gems subclass those to provide the drop-in replacements for Rails. Since the talk, a sixth gem has joined them: `yabeda-hotcell`, which I’ll get to below.\n\n### Step 4: Inputs and outputs\n\nI’ve hand-waved over how the client and the server communicate, given that the cell has no network interface.\n\nThe answer is UNIX sockets. A UNIX socket is a file on disk that two processes on the same host can talk over, and nothing about it is routable on a network. A cell exposes two of them in a shared directory: one for work, and one for metadata and control.\n\nAnd the magic bit is that `sendmsg()` can pass open file descriptors over a UNIX socket. The Hot Cell client in your app opens the upload and the output file, and hands those descriptors to the cell. The cell reads and writes through them but never sees a path, and has no access to the rest of your filesystem. Inputs are read-only and outputs are write-only, and the kernel enforces it. Passing descriptors also [closes off the class of attacks](https://basecamp.github.io/hotcell/docs/design/descriptors/) that use symbolic links to traverse paths.\n\n### Step 5: Doing the work\n\nThis part is a supervisor and workers, like Puma or Solid Queue. The [request lifecycle](https://basecamp.github.io/hotcell/docs/request-lifecycle/) walks through each step.\n\nThe supervisor is pid 1 in the cell. It accepts connections, queues them, hands each to a worker, enforces the wall-clock deadline, kills the process group, and reaps. It never reads a request and never evaluates a byte of image data.\n\nEach request is handled by a forked worker. A successful attack only compromises that worker, which gets reaped.\n\nThat’s the finished system, and it’s what is running in production at 37signals today. Now that I’ve scared you, I’m telling you that you should be running Hot Cell in your apps, and here’s how.\n\n* * *\n\n## Part 3: Rolling it out\n\nAdd `activestorage-hotcell-client` to your app’s `Gemfile`, and [run the installer](https://basecamp.github.io/hotcell/#1-install):\n\n```console\n$ bin/rails hotcell:install\n$ find hotcell\nhotcell/Dockerfile\nhotcell/Gemfile\nhotcell/config.rb\nhotcell/operations/\nhotcell/operations/.keep\n```\n\nEverything about the cell lives in that `hotcell/` directory. The cell has its own `Gemfile`, which you should keep short, because every gem in it is inside the blast radius. `config.rb` is plain Ruby, not Rails, and sets the [cell’s limits](https://basecamp.github.io/hotcell/docs/cell-settings/#request-limits). Each operation’s own limits are clamped to these:\n\n```ruby\n# hotcell/Gemfile\nsource \"https://rubygems.org\"\n\ngem \"hotcell-server\", \"~> 1.0\"\ngem \"activestorage-hotcell-server\", \"~> 1.0\"\n```\n\n```ruby\n# hotcell/config.rb\nHotCell.limits concurrency: 4,\n               queue_size: 8,\n               queue_wait: 10,\n               deadline: 120,\n               memory: 1536 * 1024**2,\n               file_size: 256 * 1024**2\n```\n\nThe only thing the app and the cell share is a volume holding the two sockets. In the app’s Kamal config, you join the cell’s group and mount the volume:\n\n```yaml\n# config/deploy.yml -- the app\nservers:\n  web:\n    options:\n      group-add: 10001\n\nvolumes:\n  - hotcell-sockets:/run/hotcell/active_storage\n\nenv:\n  clear:\n    HOTCELL_ROOT: /run/hotcell\n    HOTCELL_GROUP: 10001\n```\n\nThe cell is a Kamal accessory, because Kamal hard-codes the network for roles and `network: none` is the point:\n\n```yaml\n# config/deploy.yml -- the cell\naccessories:\n  active_storage:\n    image: your.registry.com/your-image:latest\n    roles: [ web, jobs ]\n    network: none\n    volumes:\n      - hotcell-sockets:/run/hotcell/cell\n    options:\n      cpus: 2\n      memory: 2g\n      memory-swap: 2g\n      pids-limit: 512\n      read-only: true\n      cap-drop: ALL\n      security-opt: no-new-privileges:true\n      user: 10001:10001\n      tmpfs: /tmp:rw,nosuid,nodev,noexec,size=512m\n    env:\n      clear:\n        HOTCELL_DIR: /run/hotcell/cell\n```\n\nThe volume matches the app’s. The resource limits cap CPU, memory (with swap pinned equal so the limit holds), and processes, so a fork bomb dies in the cell. And every one of `network: none`, `read-only`, `cap-drop`, and `no-new-privileges` is a [security property](https://basecamp.github.io/hotcell/docs/container/#security-flags). If you omit one, the protection is gone and the cell keeps serving requests exactly as before.\n\nThen pick the [Hot Cell twin](https://basecamp.github.io/hotcell/docs/active-storage/#classes) of each Active Storage class you use:\n\n| `ActiveStorage`                        | `ActiveStorage::HotCell::Client` |\n| -------------------------------------- | -------------------------------- |\n| `Transformers::Vips`                   | `Transformers::Image::Vips`      |\n| `Transformers::ImageMagick`            | `Transformers::Image::Magick`    |\n| `Analyzer::ImageAnalyzer::Vips`        | `Analyzers::Image::Vips`         |\n| `Analyzer::ImageAnalyzer::ImageMagick` | `Analyzers::Image::Magick`       |\n| `Analyzer::VideoAnalyzer`              | `Analyzers::Video::FFprobe`      |\n| `Analyzer::AudioAnalyzer`              | `Analyzers::Audio::FFprobe`      |\n| `Previewer::MuPDFPreviewer`            | `Previewers::Pdf::Mutool`        |\n| `Previewer::PopplerPDFPreviewer`       | `Previewers::Pdf::Poppler`       |\n| `Previewer::VideoPreviewer`            | `Previewers::Video::FFmpeg`      |\n\nSet the three Active Storage configs as above, and point Hot Cell at the sockets:\n\n```ruby\n# config/initializers/hotcell.rb\nHotCell.root  = ENV[\"HOTCELL_ROOT\"]\nHotCell.group = ENV[\"HOTCELL_GROUP\"]\n```\n\nOn the cell side, require the operations that match. The cell isn’t a Rails app, so there’s no Zeitwerk; requiring a file is what serves its operation. This is also where you [set per-operation limits](https://basecamp.github.io/hotcell/docs/operation-api/#change-a-shipped-operations-limits):\n\n```ruby\n# hotcell/operations/active_storage.rb\nrequire \"active_storage/hot_cell/server/transformers/image/vips\"\nrequire \"active_storage/hot_cell/server/analyzers/image/vips\"\nrequire \"active_storage/hot_cell/server/analyzers/media/ffprobe\"\nrequire \"active_storage/hot_cell/server/previewers/pdf/mutool\"\nrequire \"active_storage/hot_cell/server/previewers/video/ffmpeg\"\n\nActiveStorage::HotCell::Server::Transformers::Image::Vips\n  .limits file_size: 256 * 1024**2\n\nActiveStorage::HotCell::Server::Previewers::Pdf::Mutool\n  .limits deadline: 30\n```\n\nThat’s all it takes to port a Rails app that’s using vanilla Active Storage!\n\n1. Configure Active Storage.\n2. Configure the cell’s `Gemfile` and `config.rb`.\n3. Extend the Kamal (or Kubernetes) config.\n\nOnce a file type is handled by the cell, consider [removing its packages](https://basecamp.github.io/hotcell/docs/active-storage/#remove-packages-from-the-application-image) (libvips, ffmpeg, and so on) from your application image to reduce the attack surface.\n\nThe [Hot Cell documentation](https://basecamp.github.io/hotcell/) covers all of this, including [every container flag](https://basecamp.github.io/hotcell/docs/container/#container-flags) and [how to size the limits](https://basecamp.github.io/hotcell/docs/tuning/).\n\n### Observability\n\nWe can’t trust the workers, since any one of them may have been compromised. But we can trust the supervisor, and we can trust your Rails app, and between them they can give us fantastic visibility into image processing:\n\n1. **[Events in the app](https://basecamp.github.io/hotcell/docs/observability/#per-call-notification).** Every call publishes a `perform.hot_cell` Active Support notification with the operation, outcome, cause, bytes in and out, and timing. A dead cell shows up here too, as `unavailable`.\n2. **[Metrics from the supervisor](https://basecamp.github.io/hotcell/docs/observability/#cell-metrics).** The app asks the cell’s control socket for metrics like queue depth, running workers, and kills by cause.\n3. **[Logs from the supervisor](https://basecamp.github.io/hotcell/docs/observability/#cell-log).** One JSON object per event on stdout, so you can see when a worker was killed and why.\n\nIn the talk, wiring those into your app was left as an exercise using our examples. In v1.0 they ship in the gems:\n\n- `HotCell::LogSubscriber`, in `hotcell-client`, writes one line to the Rails log for every call.\n- The `yabeda-hotcell` gem records [Yabeda](https://github.com/yabeda-rb/yabeda) metrics for every call, plus gauges scraped from each cell’s control socket. Add the gem and call `Yabeda::HotCell.install!`.\n- `HotCell::HealthController` and `HotCell::DiagnosticsController`, in `hotcell-client`, give you a public health check and an authenticated diagnostic endpoint that does a real round trip through the work socket.\n\nThe Hot Cell docs list the [alerts we recommend](https://basecamp.github.io/hotcell/docs/observability/#recommended-alerts).\n\nThis is the dashboard we use for Basecamp.\n\n### The cost\n\nIn the talk, I said Hot Cell costs about 8ms per call in our environment. Since then, we’ve brought that down: **in Basecamp’s production environment, the overhead is now about 3.4ms per call**.\n\nThat’s a cheap price to know you won’t be owned the next time a zero-day is found in an image library.\n\n### Coloring outside the box\n\nHot Cell isn’t only for Active Storage:\n\n- You can run multiple cells per host, which gives you multiple queues with different depths, timeouts, and deadlines.\n- An operation can take multiple input and output files.\n- You can bring your own container. A [conformance test](https://basecamp.github.io/hotcell/docs/conformance/) tells you whether it’s configured properly.\n- Every operation’s limits are configurable.\n\nA [custom operation](https://basecamp.github.io/hotcell/docs/operation-api/) is shaped like an Active Job. It lives in `hotcell/operations/`, has a routing name and its own default limits, and does its work in `perform`:\n\n```ruby\n# hotcell/operations/extract_text.rb\nclass ExtractTextOperation < HotCell::Operation\n  operation \"extract_text\"\n  limits deadline: 30.seconds, memory: 1280.megabytes\n\n  def perform(inputs, outputs, format:, pages: [])\n    source, = inputs\n    destination, = outputs\n\n    complicated_image_manipulation(source, destination, format:, pages:)\n  end\nend\n```\n\nIn the app, a [thin client class](https://basecamp.github.io/hotcell/docs/client-api/#write-a-client) names the cell and the operation. Where a job has `perform_later`, a client has `perform_in_hotcell`, a blocking call that serializes the arguments, passes the descriptors, and returns the operation’s result:\n\n```ruby\nclass ExtractText < HotCell::Client\n  hotcell \"documents\"\n  operation \"extract_text\"\nend\n\nFile.open(pdf_path, \"rb\") do |source|\n  File.open(text_path, \"wb\") do |destination|\n    @result = ExtractText.perform_in_hotcell(source, destination, format: \"txt\", pages: [1, 2])\n  end\nend\n```\n\nOpening the files yourself is the only hoop to jump through, and usually the class you write hides it.\n\n### What production taught us\n\nHEY and Fizzy are relatively modern apps that use vanilla Active Storage, and porting them was no sweat.\n\nBasecamp was harder. Basecamp predates Active Storage (Active Storage was extracted from it), and a lot of its attachment code never moved over, so I had to write custom operations. Basecamp has 12 of them in its cell so far. Basecamp also uses attachments heavily; for some reason our customers love sending each other memes.\n\nWe had outages along the way, and I rolled the lessons back into the library:\n\n- **The OpenMP thread pool.** ImageMagick, and the libraries libvips delegates to, size their OpenMP thread pools from the host’s core count, not the container’s `cpus` quota. On a 98-core production host that’s 98 threads at 8MB of stack each, which blew through the worker’s memory limit, and 285 workers died. The fix is to [set `OMP_NUM_THREADS` and `OMP_THREAD_LIMIT`](https://basecamp.github.io/hotcell/docs/container/#bound-the-openmp-thread-pools) in the image, forwarded to every tool the cell runs.\n- **The scratch disk filled.** ImageMagick wrote its pixel cache to `/tmp`, outside the per-request directory, and cleans it up only on a clean exit. Every killed worker left its cache behind, until the 4GB scratch filled on six hosts. While it was full, 3,393 files were marked permanently unreadable. The fixes were to point `TMPDIR` and `MAGICK_TMPDIR` at the request’s directory ([hotcell#51](https://github.com/basecamp/hotcell/pull/51)), empty the scratch at boot ([hotcell#52](https://github.com/basecamp/hotcell/pull/52)), and [size ImageMagick’s limits](https://basecamp.github.io/hotcell/docs/imagemagick/#set-the-limits) to a worker’s share of the scratch.\n\nAllocate some time for tuning. Size `file_size` and the deadlines from what your real uploads take, then watch `killed` by cause. The [tuning guide](https://basecamp.github.io/hotcell/docs/tuning/) covers how.\n\n### What’s next\n\nAt Rails World I said Hot Cell wasn’t 1.0 yet because we were waiting for Rails 8.2, and because I wanted to ship more of the observability features. The observability features are now in the gems.\n\nI’m still interested in [Linux Landlock](https://github.com/basecamp/hotcell/issues/13). Landlock lets a process ratchet down its own permissions so that it can never regain them, even if an attacker takes it over. That’s a nice belt-and-suspenders addition, and if you know Landlock, I’d love to talk.\n\n* * *\n\n## Part 4: The moment we are in\n\nIs this AI’s fault? Well, it’s easy to assign blame, and you’d be forgiven for doing so. AI chained several low-severity flaws into a critical-severity attack, and it made an effective embargo impossible.\n\nBut I want to suggest a more nuanced view. I’m seeing things trend in a good direction, and I want to be optimistic. I don’t want Terminator, I want Star Trek. The alternative is that we all quit our jobs, turn off our computers, and become sheep farmers (I guess!?).\n\nThat HackerOne chart I showed you to scare you is the system working as designed. People are finding vulnerabilities and reporting them responsibly, which is exactly what we want. It’s frustrating to deal with, but there are a finite number of vulnerabilities, and the number has to come back down at some point.\n\nAnd when I said 378 vulnerabilities were found this year, what I should have said is that 378 vulnerabilities were **fixed**. Responsible maintainers are fixing the problems and shipping updates, and those libraries are trending in the right direction.\n\nHere’s what you can do to help us get through this faster:\n\n- **Vulnerability reports should come with their own agent skills.** After the CVE, I published [rails/rails-forensics-CVE-2026-66066](https://github.com/rails/rails-forensics-CVE-2026-66066), extracted from my investigation of our own apps at 37signals. Point your agent at it and at your application, and ask whether you were vulnerable (probably) and whether you were exploited. It will go through your Active Storage records and tell you whether you need to rotate your secrets.\n- **Attack your own work.** While building Hot Cell, I ran an adversarial review on almost every commit, and Jeremy helped me red-team it by setting agents loose to break in. They found real gaps. That doesn’t make Hot Cell perfect, but it has fewer problems than it would have.\n- **Improve or replace existing systems.** The only way through this period is for software to get better, or to be replaced. Hot Cell, baby!\n\nDon’t be too scared. Be proactive and constructive, and the future will be Star Trek, not Terminator.\n\nLive long and prosper, and go try [Hot Cell](https://github.com/basecamp/hotcell).\n",
  "dateCreated": "2026-10-06T00:45:55.302474734+00:00",
  "date_created": "2026-10-05T17:00:00+00:00",
  "date_modified": "2026-10-05T17:00:00+00:00",
  "date_published": "2026-10-05T17:00:00+00:00",
  "description": "Hot Cell v1.0 moves Active Storage’s image, video, and PDF processing out of your Rails app and into a locked-down sidecar container.",
  "external_url": "https://dev.37signals.com/hot-cell-1-0/",
  "git": {
    "commit": "ebb0941c5c06a2ab2377342d607535262a3e969e",
    "path": "items/dev-37signals-com/2026/10/2026-10-05-hot-cell-v1-0-securing-active-storage-in-the-age-of-ai.md",
    "permalink": "https://github.com/aymericbeaumet/aggr-instance/blob/ebb0941c5c06a2ab2377342d607535262a3e969e/items/dev-37signals-com/2026/10/2026-10-05-hot-cell-v1-0-securing-active-storage-in-the-age-of-ai.md"
  },
  "id": "urn:aggr:item:b2c8d8fb4a3f512a736e8747fe1be89da31b1e0e",
  "isBasedOn": {
    "@id": "https://dev.37signals.com/hot-cell-1-0/"
  },
  "mainEntity": {
    "@id": "https://dev.37signals.com/hot-cell-1-0/",
    "@type": "CreativeWork",
    "archivedAt": {
      "@id": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-05-hot-cell-v1-0-securing-active-storage-in-the-age-of-ai/#webpage"
    },
    "author": [
      {
        "@type": "Person",
        "name": "Mike Dalessio"
      }
    ],
    "dateModified": "2026-10-05T17:00:00+00:00",
    "datePublished": "2026-10-05T17:00:00+00:00",
    "headline": "Hot Cell v1.0: Securing Active Storage in the age of AI",
    "image": {
      "@type": "ImageObject",
      "caption": null,
      "height": 90,
      "url": "https://dev.37signals.com/assets/images/opengraph/hot-cell-1-0.png",
      "width": 160
    },
    "timeRequired": "PT16M",
    "url": "https://dev.37signals.com/hot-cell-1-0/",
    "wordCount": 3446
  },
  "name": "Archived snapshot: Hot Cell v1.0: Securing Active Storage in the age of AI",
  "preview": {
    "alt": null,
    "color": null,
    "height": 90,
    "placeholder": {
      "data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
      "hash": ""
    },
    "url": "https://dev.37signals.com/assets/images/opengraph/hot-cell-1-0.png",
    "width": 160
  },
  "reading_minutes": 16,
  "replicated_at": null,
  "source": "dev-37signals-com",
  "source_name": "37signals Dev",
  "summary": "Hot Cell v1.0 moves Active Storage’s image, video, and PDF processing out of your Rails app and into a locked-down sidecar container.",
  "tags": [],
  "temporalCoverage": "2026-10-06T00:45:55.302474734+00:00",
  "title": "Hot Cell v1.0: Securing Active Storage in the age of AI",
  "url": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-05-hot-cell-v1-0-securing-active-storage-in-the-age-of-ai/",
  "word_count": 3446
}