Skip to main content

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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

7

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.

8

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.

GitHubGitLabBitbucket

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

server.jsjavascript
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

next.config.jsjavascript
/** @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 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

server/api/health.get.tsjavascript
// 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

nuxt.config.tsjavascript
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).

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

main.pypython
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

Flask

app.pypython
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

Django

settings.py (env-driven) + wsgi.pypython
# 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 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)

config/puma.rbruby
# 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

app.rbruby
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

Rack (any)

config.ru + app.rbruby
# 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

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)

package.jsonjson
{
  "scripts": {
    "build": "vite build"
  }
}
// Output directory: dist  (detected automatically)
// No start command - dist/ is served from the edge with an SPA fallback.

Astro

astro.config.mjsjavascript
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

Before 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

GoRustJavaPHP frameworks (Laravel, Symfony)HugoJekyllRemix

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.

E

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 Ellie

Ready 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

Frequently asked questions