# Redirects and headers

Checked against the code on 2026-10-30 (version 0.1.0).

Rules can come from two places, with the same behaviour:

1. a `_redirects` file and a `_headers` file at the root of the deploy;
2. `[[redirects]]` and `[[headers]]` in `netlify.toml` or `seedrop.toml` (same format; `seedrop.toml` wins if both exist). These come **after** the `_redirects` / `_headers` rules.

Lines that are not understood are listed in the deploy report under `rules.problems` (file and line) and are ignored. The rule files themselves (`_redirects`, `_headers`, `netlify.toml`, `seedrop.toml`) are never served as public files.

## _redirects

One rule per line: `from  to  [status][!]  [Condition=a,b]`. Text after `#` is a comment.

```text
# permanent redirect (301 is the default)
/old-page          /new-page
/blog/*            /articles/:splat        301
/products/:id      /p/:id                  302

# rewrite: serve another file, URL stays the same
/app/*             /app/index.html         200

# custom 404 for a section
/docs/*            /docs/404.html          404

# force: applies even if /promo.html exists
/promo             /sale                   302!

# only for requests that arrive on this host
https://example.com/*   https://www.example.com/:splat   301!

# conditions
/                  /pt/                    302  Language=pt
/                  /br/                    302  Country=br

# proxy to a public address
/api/*             https://api.example.com/:splat  200

# send to a function (needs SDH_FUNCTIONS=1 on the server)
/api/*             /.netlify/functions/:splat      200
```

### What is supported

- **Status codes:** 200, 301, 302, 303, 307, 308, 404, 410. Default: 301.
- **Splat:** `*` captures the rest of the path; use it as `:splat` in the target. `/blog/*` also matches `/blog`.
- **Placeholders:** `:name` captures one path segment.
- **Absolute origin:** `https://www.example.com/*` only matches requests whose `Host` is that name. The protocol only counts when the server knows it (TLS on the socket, or `X-Forwarded-Proto` with `SDH_TRUST_PROXY=1`).
- **Order:** the first matching rule wins; `_redirects` before the config file.
- **Shadowing:** a rule without `!` does not apply when the requested path exists as a file (404 rules always apply). Add `!` to force it.
- **Query string:** kept on redirects when the target has none.
- **Conditions:** `Language=` matches the `Accept-Language` header (`pt` also matches `pt-BR`). `Country=` uses the `CF-IPCountry` header, so it only means something with Cloudflare in front — do not use it for anything that must be secure.
- **Targets:** a path starting with `/` (not `//`), or a full `http://` / `https://` address. Anything else (`//host`, `javascript:`…) is refused with a problem in the report. If a path target turns into an outside address after `:splat` is filled in, the rule is ignored.
- **Proxy:** a 200 rule whose target is `http(s)://…` fetches that address and returns its answer. Only public internet addresses are allowed — checked again at connection time, so DNS cannot switch to a private address afterwards — with no redirect following, a 10-second timeout and a 10 MB limit read as a stream. Only `GET`/`HEAD` reach it, and only the `Accept` header is forwarded.

### Not supported

- Other conditions (`Role=`, `Cookie=`) and query-parameter matching such as `id=:id`: the rule is parsed but never matches. Nothing redirects by mistake.
- In `netlify.toml`: `query`, `signed` and `headers` inside `[[redirects]]` are ignored with a warning in the report.
- Anonymous drops ignore rules that point outside the site.

## _headers

A path line (starting with `/`, wildcards and `:placeholders` allowed) followed by indented `Name: value` lines.

```text
/*
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/*.md
  Content-Type: text/markdown; charset=utf-8
```

The same in `netlify.toml`:

```toml
[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"
```

All matching blocks apply, in order; a later value replaces an earlier one with the same name.

- **`Set-Cookie` is always dropped** (with a problem in the report): every site shares the base domain with the dashboard, so a cookie could leak to other sites. Use a function to set cookies.
- On anonymous drops, `Content-Security-Policy`, `Service-Worker-Allowed` and `Access-Control-*` are dropped too, and the page is always `noindex`.

### Headers SeeDropHost sets by itself

| Header | Value |
|---|---|
| `Content-Type` | from the file extension (HTML, CSS, JS, JSON, TXT, XML, SVG, images, fonts, PDF, MP4, webmanifest); others `application/octet-stream` |
| `ETag` | from the content hash; `If-None-Match` answers `304` |
| `Cache-Control` | `public, max-age=0, must-revalidate` |
| `X-Content-Type-Options` | `nosniff` |
| `X-SDH-Deploy` | id of the deploy that answered |
| `X-Robots-Tag` | `noindex` on drafts, older deploys and anonymous drops |
| `X-SDH-Preview` | `1` when that `noindex` was added by SeeDropHost (not by your `_headers`) — the migration check uses it |

## Clean URLs, trailing slash and 404

- `/about` serves `/about.html`; `/docs/` serves `/docs/index.html`.
- Each page has one address: if only `about/index.html` exists, `/about` answers `301` to `/about/`; if only `about.html` exists, `/about/` answers `301` to `/about`. The query string is kept.
- When nothing matches, `/404.html` is served with status 404 if it exists.
