# Hostney - Build & Deploy Guide for AI agents

> How to structure an application so it deploys and runs on Hostney on the first try.
> Hostney runs your app in an isolated container (or serves static builds from the edge),
> deployed from Git. This file is the authoritative contract. Human version: https://www.hostney.com/build-guide

Last updated: 2026-09-15

## Universal rules (true for every runtime)

### Bind to 0.0.0.0 on $PORT
A server app MUST listen on 0.0.0.0 (all interfaces), on the port given by the PORT environment variable. Binding to localhost / 127.0.0.1, or hardcoding a port, is the single most common reason a build succeeds but the app never becomes reachable. Default internal ports: Node 3000, Python 8000, Ruby 3000 - but always read PORT rather than assuming.

### Environment variables & secrets
Set build-time and runtime variables in the panel. Mark any variable as a secret and its value is sealed - write-only, never shown in the UI or API again, though your app still receives it. Rotate a secret by entering a new value. Anything compiled into a static bundle is visible to browsers, so never put secrets there.

### Framework is auto-detected from your files
On connect, the platform inspects your repo and picks the runtime and framework: package.json (Node), manage.py / requirements.txt / pyproject.toml (Python), bin/rails / Gemfile / config.ru (Ruby). It sets the install command, build/start command, and output directory for you. You can override any of them in the app’s build settings.

### Commit your lockfile
Commit the lockfile for your ecosystem: package-lock.json / pnpm-lock.yaml / yarn.lock / bun.lockb for Node, and Gemfile.lock for Ruby (required - the build fails clearly without it). Ruby lockfiles must include the Linux platform; if you locked on Windows or macOS, run: bundle lock --add-platform x86_64-linux.

### The visitor IP is in a header, and it is the LAST entry, not the first
Every request reaches your container through nginx, so the socket address your framework reports is the proxy - not the visitor. Read the address from the headers we set. X-Forwarded-For is APPENDED to, never replaced: it arrives as "<whatever the caller sent>, <the address nginx actually saw>", so the RIGHTMOST entry is the one to trust and everything to its left was supplied by the caller and can say anything. Taking the leftmost entry - the advice in most snippets - reads a value any visitor can set to whatever they like. X-Real-IP carries that same address on its own. X-Forwarded-Proto is set too, and a framework that decides http-or-https from the socket will think every request was plain http, which is how redirect loops and wrong absolute URLs start. Express: app.set('trust proxy', 1), then req.ip. Next.js: read the x-forwarded-for header yourself - Next fills it in from the socket ONLY when the caller did not send one, so it is always present and never automatically trustworthy. Django: SECURE_PROXY_SSL_HEADER and USE_X_FORWARDED_HOST. Rails: Rack::Request#ip already walks it correctly. Get this wrong and every visitor looks like one address: rate limits fire for everybody at once or for nobody, geolocation returns our datacentre, and every IP in your logs is the proxy. If you enable the Hostney CDN on the domain there is one more hop in front - the edge re-stamps CF-Connecting-IP with the visitor and the last X-Forwarded-For entry then belongs to the edge, so read CF-Connecting-IP on a CDN-enabled domain.

### Databases reach mysql-host over TCP
MySQL is reachable from your container at the host mysql-host on port 3306 over TCP. For container apps we inject exactly two variables: MYSQL_HOST=mysql-host and MYSQL_PORT=3306 - and only when you have not set them yourself, because setting your own value is how you point an app at an external database instead. NOTHING ELSE IS INJECTED. Create the database and user in the panel, then set the username, password and database name yourself as environment variables named however your app reads them. DATABASE_URL and DB_HOST/DB_USERNAME/DB_PASSWORD/DB_NAME are naming conventions your framework may expect, not variables we provide - if your app reads DB_HOST, you must set DB_HOST=mysql-host. An external MySQL user must allow the 10.88.0.0/16 range (the container bridge). If a connection is refused with "access denied", check the grant before you check the password: a MySQL grant is per host, so a user created at 'localhost' works from the database server and is refused from your container, which looks exactly like a wrong password.

### MySQL requires TLS, and the certificate is self-signed
Connections to mysql-host MUST be encrypted - plaintext is refused. The server presents a self-signed certificate and there is no CA for you to verify it against, so your client has to do two things: turn TLS on, and skip certificate verification. Most drivers do neither by default. Get it wrong and the connection dies during the handshake, and several drivers report that with no readable message at all - mysql2 raises a bare stack trace - which is very easily mistaken for a wrong password. Node (mysql2): ssl: { rejectUnauthorized: false }. Django (mysqlclient): OPTIONS = {"ssl_mode": "REQUIRED"}. Rails: ssl_mode: required in database.yml. Go (go-sql-driver): add ?tls=skip-verify to the DSN. PHP (PDO): PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false. Skipping verification encrypts the connection without authenticating the server, which is acceptable here because the traffic never leaves the machine - your container reaches MySQL over the host gateway, not the network.

### Three deploy modes
Auto deploy: every push to the tracked branch builds and goes live. Build only: every push builds but stays on the current version until you promote it. Paused: pushes are ignored. Choose per app in the deployment settings.

### Promote & rollback are artifact swaps
Every successful build is stored as an artifact. Promoting a previous build swaps it back to live in seconds - no rebuild, no Git revert. Static builds are kept indefinitely; container (SSR) builds keep the five most recent successful artifacts per app, and the currently-live one is always protected.

### Health check gates every deploy
A new container must answer on its port within 30 seconds before it takes over traffic (blue/green). If it never comes up, the previous build keeps serving and the deploy is marked failed - a bad build cannot take your app down.

## Git providers

Supported: GitHub, GitLab, Bitbucket.
Connect any of the three over OAuth or a personal access token. All three support automatic deploys: a push to the tracked branch triggers a build via webhook. If a webhook can’t be registered (missing scope), the connection still saves and you can trigger builds manually from the panel.

## Build settings you can configure

- **Install command** - Runs before the build. Defaulted from your framework; override for custom dependency steps.
- **Build command** - For Node/static this builds your output. For Python/Ruby this is effectively the container start command.
- **Output directory** - Where the built static assets live (e.g. dist, build, .next). Used for static/SPA deploys.
- **Root directory** - Subfolder to build from, for monorepos. Defaults to the repo root.
- **Runtime version** - Node 24/22/20, Python 3.13/3.12/3.11, or Ruby 4.0/3.4/3.3. Or pin it in your repo (see each runtime below).
- **Internal port** - Override the port the platform expects your server on. Defaults: Node 3000, Python 8000, Ruby 3000.
- **Skip tests** - Node only. When off, the build runs your test script in CI mode and fails the deploy if tests fail.

## Runtimes

### Node.js & Next.js  (marketing page: https://www.hostney.com/nodejs)
Next.js and Nuxt run as container SSR; Express, Fastify, NestJS, Hono, and any Node server run under the Node preset.

- Detection: package.json (+ your lockfile picks npm / pnpm / yarn / bun).
- Runtime versions: Node 24, 22 (recommended), 20
- Pin the version: Set via .nvmrc, .node-version, or the "engines" field in package.json.
- Internal port: 3000
- Install: npm install (or the detected package manager equivalent)
- Start: Framework default (e.g. next start), or your "start" script / a custom command.
- Env vars: None required for a plain server. Add your own as needed. NODE_OPTIONS is set to cap memory unless you override it. If you use our MySQL, MYSQL_HOST and MYSQL_PORT arrive automatically - you set MYSQL_USER, MYSQL_PASSWORD and MYSQL_DATABASE (or whatever names your code reads) yourself.
- Notes:
  - Next.js: output: "export" is served as static from the edge; standard/standalone builds run as a live container (SSR, API routes, and ISR all work).
  - Any framework is fine as long as it listens on 0.0.0.0:$PORT.
  - MySQL from Node needs TLS: mysql2 does NOT use TLS unless you configure it, and our MySQL refuses plaintext and presents a self-signed certificate. Pass ssl: { rejectUnauthorized: false }. Without it the connection dies during the handshake and mysql2 reports it as a stack trace with no message attached - which is very easy to misread as a wrong password. See the mysql2 example below.

Example - Express (server.js):
```javascript
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.json({ framework: 'express', message: 'hello from hostney' });
});
app.get('/health', (req, res) => res.json({ ok: true }));

// Bind to 0.0.0.0 and the port Hostney provides.
const port = process.env.PORT || 3000;
app.listen(port, '0.0.0.0', () => console.log(`listening on ${port}`));
```

Example - Next.js (next.config.js):
```javascript
/** @type {import('next').NextConfig} */
module.exports = {
  // Omit "output" for SSR (runs as a container - API routes, ISR, etc.).
  // Set output: 'export' to ship a static site to the edge instead.
  // Next binds to 0.0.0.0:$PORT via `next start` - nothing else to configure.
};
```

Example - MySQL (mysql2) (lib/db.js):
```javascript
import mysql from 'mysql2/promise';

// MYSQL_HOST and MYSQL_PORT are injected for you.
// Set MYSQL_USER, MYSQL_PASSWORD and MYSQL_DATABASE in the panel.
export const pool = mysql.createPool({
  host: process.env.MYSQL_HOST,
  port: Number(process.env.MYSQL_PORT) || 3306,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE,

  // REQUIRED. Our MySQL refuses plaintext connections, and its certificate
  // is self-signed with no CA for you to verify against - so encrypt, but
  // do not verify. Leave this out and the connection fails during the
  // handshake, which mysql2 reports as a stack trace with NO message.
  ssl: { rejectUnauthorized: false },

  waitForConnections: true,
  connectionLimit: 10,
});
```

### Nuxt  (marketing page: https://www.hostney.com/nuxt)
Nuxt 3 with the Nitro engine. Server-rendered as a container, or statically generated to the edge.

- Detection: Nuxt in package.json (+ your lockfile).
- Runtime versions: Node 24, 22 (recommended), 20
- Pin the version: Set via .nvmrc, .node-version, or "engines" in package.json.
- Internal port: 3000
- Install: npm install (or detected package manager)
- Start: SSR: nuxt build then the Nitro server. Static: nuxt generate (served from the edge).
- Env vars: None required. Nitro reads PORT automatically, so SSR binding just works.
- Notes:
  - Run nuxt generate to ship a static site to the edge (no container, no app slot used).
  - A standard build runs as a live container: SSR, Nitro server routes, and server API all work.

Example - Nitro server route (server/api/health.get.ts):
```javascript
// Nuxt binds to 0.0.0.0:$PORT automatically via Nitro - nothing to configure.
// A server route looks like this:
export default defineEventHandler(() => {
  return { ok: true };
});
```

Example - Static generate (nuxt.config.ts):
```javascript
export default defineNuxtConfig({
  // Leave as SSR (a live container), or pre-render everything to the edge:
  nitro: { prerender: { crawlLinks: true, routes: ['/'] } },
});
// Build with `nuxt generate` to ship static output (no app slot used).
```

### Python  (marketing page: https://www.hostney.com/python)
Django, Flask, and FastAPI, built on a slim Python image (glibc - compiled wheels install cleanly).

- Detection: Django from manage.py; Flask and FastAPI from your dependencies (requirements.txt / Pipfile).
- Runtime versions: Python 3.13, 3.12 (recommended), 3.11
- Pin the version: Set via .python-version, requires-python in pyproject.toml, or runtime.txt.
- Internal port: 8000
- Install: pip install --no-cache-dir -r requirements.txt (Django also runs collectstatic and migrate).
- Start: Django: gunicorn <project>.wsgi:application --bind 0.0.0.0:$PORT. Flask: gunicorn app:app --bind 0.0.0.0:$PORT. FastAPI: uvicorn main:app --host 0.0.0.0 --port $PORT.
- Env vars: Django: DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS (* for testing, your fqdn in prod). Flask: SECRET_KEY. FastAPI: none required.
- Notes:
  - gunicorn/uvicorn take the bind address on the command line, which the platform sets for you.
  - Expose a module-level WSGI/ASGI callable named "app" (Flask/FastAPI) or "application" (Django wsgi.py).

Example - FastAPI (main.py):
```python
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def hello():
    return {"framework": "fastapi", "message": "hello from hostney"}

@app.get("/health")
async def health():
    return {"ok": True}

# Start (set by Hostney): uvicorn main:app --host 0.0.0.0 --port $PORT
```

Example - Flask (app.py):
```python
import os
from flask import Flask, jsonify

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "dev-key")

@app.route("/")
def hello():
    return jsonify(framework="flask", message="hello from hostney")

@app.route("/health")
def health():
    return jsonify(ok=True)

# Start (set by Hostney): gunicorn app:app --bind 0.0.0.0:$PORT --workers 2
```

Example - Django (settings.py (env-driven) + wsgi.py):
```python
# settings.py - read config from the environment
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")

# wsgi.py exposes a module-level "application":
#   application = get_wsgi_application()
# Start (set by Hostney):
#   gunicorn <project>.wsgi:application --bind 0.0.0.0:$PORT --workers 2
```

### Ruby  (marketing page: https://www.hostney.com/ruby)
Ruby on Rails, Sinatra, and any Rack app (Roda, Grape, Hanami, Padrino), all served by Puma.

- Detection: Rails from bin/rails + gem "rails"; Sinatra from gem "sinatra" + config.ru; otherwise generic Rack from config.ru.
- Runtime versions: Ruby 4.0, 3.4 (recommended), 3.3
- Pin the version: Set via .ruby-version, the ruby "x.y.z" directive in your Gemfile, or Gemfile.lock.
- Internal port: 3000
- Install: bundle install (from your committed Gemfile.lock).
- Start: Rails: bundle exec puma -C config/puma.rb. Sinatra: bundle exec rackup -s puma -o 0.0.0.0 -p $PORT. Rack: bundle exec puma config.ru -b tcp://0.0.0.0:$PORT.
- Env vars: Rails: RAILS_MASTER_KEY (contents of config/master.key) or SECRET_KEY_BASE. DATABASE_URL optional (falls back to DB_* against mysql-host). RAILS_ENV/RACK_ENV/PORT are set for you.
- Notes:
  - Gemfile.lock is required. Lock for Linux: bundle lock --add-platform x86_64-linux.
  - Rails builds multi-stage and precompiles assets; Node + Yarn are auto-installed if you use jsbundling/cssbundling.
  - Rails apps run bin/rails db:prepare at boot (creates the DB if missing, runs pending migrations - idempotent).
  - Values are read straight from ENV[...] at process start (no .env file baked in).

Example - Rails (Puma) (config/puma.rb):
```ruby
# Puma binds to 0.0.0.0 on the port Hostney injects.
port ENV.fetch("PORT", 3000)
bind "tcp://0.0.0.0:#{ENV.fetch('PORT', 3000)}"
environment ENV.fetch("RAILS_ENV", "production")

workers ENV.fetch("WEB_CONCURRENCY", 2).to_i
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
threads threads_count, threads_count

preload_app!
```

Example - Sinatra (app.rb):
```ruby
require "sinatra/base"
require "json"

class App < Sinatra::Base
  set :bind, "0.0.0.0"
  set :port, ENV.fetch("PORT", "3000").to_i
  set :environment, :production

  get("/")       { { framework: "sinatra" }.to_json }
  get("/health") { { ok: true }.to_json }
end
# Start (set by Hostney): bundle exec rackup -s puma -o 0.0.0.0 -p $PORT
```

Example - Rack (any) (config.ru + app.rb):
```ruby
# app.rb - a bare Rack app
class App
  def call(env)
    body = { framework: "rack" }.to_json
    [200, { "content-type" => "application/json" }, [body]]
  end
end

# config.ru
require "./app"
run App.new
# Start (set by Hostney): bundle exec puma config.ru -b tcp://0.0.0.0:$PORT
```

### Static & SPA  (marketing page: https://www.hostney.com/spa)
React, Vue, Svelte, Angular, Astro, SvelteKit, Vite, Gatsby, and more - built and served from the edge.

- Detection: Framework from package.json; the build command and output directory are set for you.
- Runtime versions: Built with Node 24, 22 (recommended), or 20.
- Pin the version: Set via .nvmrc, .node-version, or "engines" in package.json.
- Internal port: n/a - no running server. Output is served from the edge cache.
- Install: npm install (or detected package manager)
- Start: None. The build output (e.g. dist) is published to the edge; client-side routes get an SPA fallback.
- Env vars: Build-time only: VITE_, NEXT_PUBLIC_, REACT_APP_ prefixes are baked into the bundle at build time. Never put secrets in a client bundle.
- Notes:
  - No container and no app slot used - ship as many static apps as you like.
  - Deep links and refreshes on client routes resolve to your app via the SPA fallback.
  - Custom domains get automatic HTTPS.

Example - Vite (React / Vue / Svelte) (package.json):
```json
{
  "scripts": {
    "build": "vite build"
  }
}
// Output directory: dist  (detected automatically)
// No start command - dist/ is served from the edge with an SPA fallback.
```

Example - Astro (astro.config.mjs):
```javascript
import { defineConfig } from 'astro/config';

// Default static output builds to ./dist and serves from the edge.
export default defineConfig({});
// Build: astro build → output directory: dist
```

## Pre-deploy checklist

- [ ] Server listens on 0.0.0.0 using process.env.PORT / ENV["PORT"] (not localhost, not a hardcoded port).
- [ ] A /health route returns 200 quickly (used to confirm the app is up).
- [ ] Lockfile is committed (Gemfile.lock for Ruby - and add the x86_64-linux platform).
- [ ] Required env vars are set in the panel before the first deploy (see your framework above).
- [ ] Framework-required secrets (Rails master key, Django secret key, Flask secret) are marked as secrets.
- [ ] Database access, if any, targets mysql-host:3306 over TCP with panel-created credentials, over TLS with certificate verification disabled (the server certificate is self-signed and plaintext is refused).
- [ ] Anything that logs, rate-limits or geolocates by IP reads the LAST entry of X-Forwarded-For (or X-Real-IP), never the socket address and never the first entry.

## Not supported as Applications

Go, Rust, Java, PHP frameworks (Laravel, Symfony), Hugo, Jekyll, Remix. These are not deployable as Applications today. Go is on the roadmap.

### WordPress and Drupal: one-click install
Both are installed from the Marketplace, not from Git. Pick the subdomain and we provision the site, its MySQL database and user, the PHP version, and the web server config for you. Drupal 10 and 11 are built with Composer and ship with a project-local Drush; its docroot is the web/ subfolder and its trusted host pattern is pinned to the FQDN at install time. Admin and database credentials are shown once, at install.

### Custom PHP and plain static HTML: subdomain website type
Create the subdomain and choose the website type (Apache, or Nginx with PHP). Upload over SFTP, Git-clone over SSH, or use the file manager. Each site gets its own containerised PHP-FPM pool and its own PHP version.

A subdomain hosts exactly one thing: a Git application, a WordPress install, or a Marketplace app. The platform refuses the second one rather than overwriting the first, so pick the path before you pick the subdomain.
