For developers & AI agents
Build it right the first time
A clear contract for what a Hostney-ready app looks like - how it binds its port, reads its config, and gets detected - so you (or your coding agent) skip the trial and error. There’s a machine-readable version at /build-guide.txt.
14 days free. No credit card. No commitment.
0.0.0.0:$PORT
The one rule
/build-guide.txt
Agent-readable
Copy-paste examples
For every runtime
Using an AI coding assistant?
Point it at the plain-text version of this guide so it configures your deploy correctly. It’s the same information as this page, formatted for machines.
The contract
Rules that apply to every app
Get these right and your app runs. Most first-deploy problems trace back to the first one on this list.
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.
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.
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.
Databases reach mysql-host over TCP
MySQL is reachable from your container at the host mysql-host on port 3306 over TCP. The host is injected for you. Create the database and user in the panel. An external MySQL user must allow the 10.88.0.0/16 range (the container bridge). Configure your app via a DATABASE_URL or discrete DB_HOST/DB_USERNAME/DB_PASSWORD/DB_NAME variables.
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
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.
What 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.
By runtime
Pick your stack
Each runtime detects itself from your files. Here’s exactly what the platform expects - and a minimal app to start from.
Node.js & Next.js
Overview →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).
- Versions
- Node 24, 22 (recommended), 20
- Pin 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.
- 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.
Minimal examples
Express
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}`));Next.js
/** @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.
};Nuxt
Overview →Nuxt 3 with the Nitro engine. Server-rendered as a container, or statically generated to the edge.
- Detection
- Nuxt in package.json (+ your lockfile).
- Versions
- Node 24, 22 (recommended), 20
- Pin 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.
- 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.
Minimal examples
Nitro server route
// 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 };
});Static generate
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
Overview →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).
- Versions
- Python 3.13, 3.12 (recommended), 3.11
- Pin 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.
- 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).
Minimal examples
FastAPI
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 $PORTFlask
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 2Django
# 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 2Ruby
Overview →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.
- Versions
- Ruby 4.0, 3.4 (recommended), 3.3
- Pin 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.
- 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).
Minimal examples
Rails (Puma)
# 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!Sinatra
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 $PORTRack (any)
# 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:$PORTStatic & SPA
Overview →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.
- Versions
- Built with Node 24, 22 (recommended), or 20.
- Pin 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.
- 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.
Minimal examples
Vite (React / Vue / Svelte)
{
"scripts": {
"build": "vite build"
}
}
// Output directory: dist (detected automatically)
// No start command - dist/ is served from the edge with an SPA fallback.Astro
import { defineConfig } from 'astro/config';
// Default static output builds to ./dist and serves from the edge.
export default defineConfig({});
// Build: astro build → output directory: distBefore you deploy
The pre-flight checklist
Run through this once and the first deploy should just work.
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.
Other paths
Not everything is an Application
Some workloads have a better route on Hostney than the Git pipeline. Take the right one before you pick a subdomain.
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.
Not deployable as an Application today
These are not deployable as Applications today. Go is on the roadmap.
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.
Or just ask Ellie
Prefer not to wire it yourself? Ellie, the built-in assistant, can create databases, set environment variables, and walk a deploy through the panel for you - in plain English.
Meet EllieReady when you are
Deploy your first app
Connect a repo, push, and let the pipeline do the rest. Start free for 14 days, no credit card.
Questions