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.md.
14 days free. No credit card. No commitment.
0.0.0.0:$PORT
The one rule
Same price
On renewal, every year
99.99%
Uptime, last 12 mo
Daily
Backups kept up to 30 days
190+
Tasks Ellie handles
Hand this to your coding agent
Point it at the markdown version of this guide so it configures your deploy correctly. It is the same information as this page, formatted for machines.
Deploying a specific stack?
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. 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.
- 6
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.
- 7
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.
- 8
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.
- 9
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.
- GitHub
- GitLab
- Bitbucket
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. 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.
- 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.
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.
};MySQL (mysql2)
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
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, over TLS with certificate verification disabled (the server certificate is self-signed and plaintext is refused).
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
- Go
- Rust
- Java
- PHP frameworks (Laravel, Symfony)
- Hugo
- Jekyll
- Remix
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.
Go deeper
One page per runtime
The same contract, with the deploy pipeline drawn out and a live view of what a running app looks like.
Or just ask 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