# Deploy a Rails blog API with PostgreSQL on Yeeted

A Yeeted sample: **Rails 8 API-only** on managed postgres. The tree is what
`rails new` gives you:

```
rails new blog-rails --api --database=postgresql \
  --skip-solid --skip-docker --skip-kamal --skip-ci --skip-git \
  --skip-jbuilder --skip-brakeman --skip-rubocop --skip-test
```

Plus one model, two controllers, a migration, one pinned gem (below) and the
three files every example here carries (`yeeted.yaml`, `smoke.sh`, this
README). Configuration largely follows the generated project. That is the point: it deploys close to
as generated.

## What it demonstrates

- **The `ruby rails` build branch**: `bundle install` on `ruby:3.3-slim`, a
  per-build `SECRET_KEY_BASE`, and `db:migrate` as the RELEASE step, run at
  boot against `DATABASE_URL` under Rails' own migration advisory lock.
- **`DATABASE_URL` wins over `config/database.yml`.** The generated production
  section hardcodes `database: blog_rails_production` and a
  `BLOG_RAILS_DATABASE_PASSWORD` that is never set here. Rails still connects
  to the attached database, because `DATABASE_URL` takes precedence. Nothing
  needs editing.
- **`force_ssl` is fine.** `config.force_ssl = true` and
  `config.assume_ssl = true` are both on in the generated `production.rb`. The
  gateway terminates TLS and forwards `X-Forwarded-Proto`, so requests are not
  redirect-looped and health checks answer 200.

## The one edit `rails new` needed

```ruby
gem "json", "~> 2.7"
```

json 3.0 dropped the **positional** options argument from `JSON.parse`, and
ActiveSupport 8.1 still calls it that way:

```
activesupport-8.1.3.1/lib/active_support/json/decoding.rb:25
  data = ::JSON.parse(json, options)
```

so every JSON request body raises `ArgumentError: wrong number of arguments
(given 2, expected 1)`, which Rails reports as
`ActionDispatch::Http::Parameters::ParseError` and a **400 with an empty
body**. Form-encoded bodies are unaffected, which is what makes it look like
the client is at fault rather than the stack.

This is not platform-specific: The dependency combination described above resolves
json 3.x and behaves the same anywhere. Pinning to 2.x is the fix until
ActiveSupport catches up.

## Two things worth copying

**Give an API app a root route.** The root JSON index gives visitors a useful
starting point. This example declares `/healthz` as its health-check path.

**Rescue `ParameterMissing`.** Production Rails turns an unhandled one into a
400 with an *empty body*, which reads as the platform rejecting the request
rather than the app. `PostsController` rescues it into a JSON message.

## Endpoints

| Route | What it is |
|---|---|
| `GET /` | a JSON index: app name, Rails version, endpoints |
| `GET /healthz` | `ok` |
| `GET /api/posts` | the 50 newest posts |
| `POST /api/posts` | `{"post":{"title":"…","body":"…"}}`, 201 or 400 |

## Known cosmetic noise

The generated Gemfile carries `image_processing`, so every boot logs
`Using vips to process variants requires the libvips library`. This app has no
ActiveStorage variants; the gem is left in because the tree is meant to be what
`rails new` produces, and the warning is harmless.

## No asset pipeline

`--api` means there is no `assets:precompile` task at all, and the build says
so rather than failing:

```
yeeted: no assets:precompile task (API-only, or no asset pipeline) - skipping
```

A full-stack Rails app is a different story. `jsbundling-rails` and
`cssbundling-rails` need node and yarn, which `ruby:3.3-slim` does not carry,
and that precompile FAILS the build by design. It used to be swallowed, and
the image shipped and 500'd on its first asset.

Tests: `./smoke.sh https://...` (smoke). `--skip-test` was passed: Rails model
tests want a database, and every example here runs its unit tests with no
services at all.

## Run locally

Use Ruby 3.3 or newer, Bundler, and PostgreSQL with a disposable database.
From `ruby/blog-rails` inside the examples tree, set `DATABASE_URL` to that
local database using your environment, then run:

```sh
bundle install
bin/rails db:migrate
bin/rails server -b 0.0.0.0 -p 3000
```

Open `http://localhost:3000` for the JSON index. This example has no in-memory
or SQLite fallback and no standalone unit tests. The checked-in Gemfile and
lockfile are the source of truth for the dependency versions described above.

## Deploy and verify

Follow the [deployment walkthrough](getting-started.md). Upload this
folder, including its lockfile, migrations, and `yeeted.yaml`. The manifest
requests PostgreSQL; the platform supplies `DATABASE_URL` and runs Rails
migrations during the release step. This is an API-only app, so there is no
asset compilation step.

```sh
sh smoke.sh "$PREVIEW_URL"
curl -i "$PREVIEW_URL/api/posts" -H 'Content-Type: application/json' \
  -d '{"post":{"title":"My first Yeeted post","body":"Hello from Rails"}}'
curl -fsS "$PREVIEW_URL/api/posts"
```

A valid write returns 201. The list response contains the post and
`"store":"postgres"`. A blank title returns 400. The smoke script checks
health, the root index, creation, reading, and validation. Wrap request fields
in `post`; this is different from the Next.js example's JSON shape.

## What to change before real use

The API has no author authentication or per-user ownership. Add those before
collecting real users' content. Keep a database backup before experimenting
with schema changes; rolling back an application version does not reverse
Rails migrations. A full-stack Rails application with JavaScript or CSS
bundling has different build requirements from this API-only example.

[All examples](index.md) · [Deploy an example](getting-started.md)
