Skip to main content
Blog|
Latest news

Deploy Node, Python, and Ruby apps on Hostney

|
Aug 6, 2026|14 min read
LATEST NEWSDeploy Node, Python, and Rubyapps on HostneyHOSTNEYhostney.comAugust 6, 2026

Hostney has been able to build and serve static sites and single-page apps from a Git repository for a while. What is new is the other half: long-running server-rendered applications. You can now connect a repository and deploy Next.js, Nuxt, a plain Node HTTP server, Django, Flask, FastAPI, Ruby on Rails, Sinatra, or any Rack app, and we run it as a real container with a live process behind it.

That changes what the Applications page is for. It is no longer just a place to drop a built bundle. It is a place to run an application, watch it, restart it, roll it back, and point it at a database. This post walks through what you get, how the pieces fit together with the SPA deployments you may already be using, and where to find each control.

Static and server-rendered are two different things#

The distinction matters because it decides how your app is served, what it costs you, and what you can see afterwards.

A static or SPA deployment produces files. React, Vue, Svelte, Angular, Astro, SvelteKit, Vite, Gatsby, Eleventy, VitePress, Docusaurus and the rest all build down to HTML, CSS, and JavaScript. We take that build output and serve it from the edge. There is no process running anywhere. It is fast, it is cheap, and it is available on every plan with no slot limit.

A server-rendered deployment produces a container. Next.js in server mode, Nuxt, Express, Fastify, NestJS, Django, Flask, FastAPI, Rails, Sinatra: these need a process that stays alive, accepts a request, does work, and answers. We build your repository into a container image, start it on the worker, health-check it, and route your subdomain to it. Because it holds real memory and real CPU for as long as it runs, it counts against your plan’s SSR slot allowance.

Both are configured the same way, from the same page, with the same Git connection. You just get more surface area on the server-rendered side, because there is something running to look at.

Running both at once#

The two are not alternatives. Most real projects want both, and the natural shape on Hostney is one subdomain per piece.

Put your marketing front end or admin dashboard on one subdomain as a static SPA, and your API on another as a container. www serves the built React bundle from the edge with no SSR slot consumed. api runs FastAPI or Express in a container. Both live under the same domain, both deploy from the same repository if you use a monorepo (there is a Root directory setting for exactly that), and both rebuild on push.

The one rule to know is that a subdomain hosts exactly one thing. An application or a WordPress install, never both. If the subdomain dropdown looks short when you go to connect a repository, that is why: it only lists subdomains that are genuinely empty.

Connecting a repository#

You connect a Git provider once, at the account level, and then reuse it for every app.

GitHub, GitLab, and Bitbucket are all supported through OAuth, and all three get a webhook registered automatically when you connect a repository. Push to the branch you selected and a build starts. There is no CI file to write and nothing to install in your repo.

From the Applications page, Connect repository opens a side panel with four decisions:

  1. Subdomain. Where the app will live. Only empty subdomains are offered.
  2. Repository and branch. The branch you pick is the branch we watch.
  3. Deployment type. The framework. In most cases you can leave it on auto-detect, because we read your manifests: package.json for Node, requirements.txt , pyproject.toml or manage.py for Python, and Gemfile , bin/rails or config.ru for Ruby. Pick a framework explicitly if you want to override what detection would choose, for example to ship a static export of a Next.js app instead of running it as a container.
  4. Auto-deploy. On by default. Turn it off if you would rather trigger builds by hand.

Runtime versions come from your repository rather than from a dropdown you have to remember to update. Python reads .python-version , then requires-python in pyproject.toml , then a Heroku-style runtime.txt . Ruby reads .ruby-version , then the ruby DSL line in your Gemfile, then the RUBY VERSION block in Gemfile.lock . Node is the exception and is set per app in Build settings, since package.json engine fields are so often stale.

Ruby has one hard requirement worth stating up front: Gemfile.lock must be committed. That is the same standard Heroku, Render, and Fly enforce, and for the same reason. Without a locked dependency graph a build six months from now can quietly resolve different gem versions than the ones you tested. We fail the build immediately with a clear message rather than shipping something you did not test.

What a build actually does#

Every build runs in a disposable, resource-capped container: one CPU, two gigabytes of memory, bounded scratch space, and a per-customer cgroup slice so nobody’s build can starve anybody else’s site. The same container isolation model we use for websites applies to builds.

Sensible defaults are applied per framework and you can override any of them:

  • Django runs collectstatic and migrate during the image build, so assets are baked in and the schema is current before the first request arrives.
  • Rails runs assets:precompile with the dummy secret key trick, so you do not need your real master key at build time. If your Gemfile uses jsbundling-rails or cssbundling-rails, we detect it and install Node and Yarn in the build stage automatically.
  • Rails migrations run at container boot, not at build time, through a small entrypoint shim that calls db:prepare . It creates the database if it is missing, runs pending migrations, and does nothing when everything is current. ActiveRecord takes an advisory lock, so simultaneous starts during a blue-green swap cannot double-migrate.

Finished images are checked against a 2 GB cap on the uncompressed sum of all layers. We log the size at the end of every build, successful or not, so you can watch it creep up release over release before it becomes a problem.

When the new container starts, it has to pass a TCP health check before it takes over traffic. The old container keeps serving until the new one answers. If the new one never binds, we roll back and hand you the last fifty lines the container printed, which is almost always enough to see why.

That health check is also the reason the port contract matters. Bind to 0.0.0.0 on the port in the PORT environment variable: 3000 for Node and Ruby, 8000 for Python. Next.js, Nuxt, and the default Python and Ruby start commands do this for you. A custom Express or Fastify server does not, and app.listen(3000) on localhost is the single most common reason a build succeeds and the deploy still fails.

The Lifecycle tab#

Once a build is promoted, the Lifecycle tab shows you the container that is actually serving traffic.

Three tiles refresh every ten seconds while you are looking at the page, and the poll pauses when you switch browser tabs so we are not making requests nobody is reading:

  • CPU as a percentage of your allocated quota. Five to thirty per cent is a normal steady state. Sustained ninety per cent means heavy load, CPU-bound work sitting on the request path, or a hot loop.
  • Memory against the container’s limit. Each app gets at least 512 MB, derived from your plan’s memory allocation and divided across your SSR slots so that your websites, PHP, and cron are not starved by your apps. A memory line that climbs over hours and never comes back down is a leak, and a restart will clear the symptom while you go find the cause.
  • Uptime since the last container start. It resets on a restart, a redeploy, or a crash that systemd recovered from on its own.

There is a Restart button next to them, gated behind a typed confirmation because the site returns a brief 502 while the new container takes over. Restart is the right tool for clearing in-memory state, for picking up an environment variable you just changed (they are read once at container start), and for an app that is wedged without crashing. It is the wrong tool for an app that is crash-looping, because systemd is already restarting it and the bug is still there.

Static and SPA deployments show a single card here instead. There is no container to inspect, because there is no container.

Live logs#

Below the tiles, the Lifecycle tab streams two log surfaces at once.

App logs are whatever your code writes to stdout and stderr inside the container. Your console.log , your print , your framework warnings, your request logs. Platform logs are systemd journal entries for the container’s service unit: starts, stops, exit codes, out-of-memory kills, automatic restart attempts.

Having both in one place is the point. A container that dies every ninety seconds looks like nothing at all in your application log and looks like a repeating restart in the platform log. A chip filter flips between All, App, and Platform without dropping the stream.

The view pins to the bottom as lines arrive, and pauses auto-scroll the moment you scroll up to read something, with a Jump to latest button to get back. There is a Pause toggle and a Download action that saves the current buffer as a .log file. The buffer holds the last thousand lines, so download before it rolls off if you need to keep something.

If the container is stopped, you get a warning banner rather than a silently empty pane, and the stream reconnects by itself when the container comes back.

For anything the log viewer will not give you, SSH access and the browser terminal are still there, and scheduled work still belongs in cron jobs rather than in a second container. Background job workers such as Sidekiq and Resque are not supported yet: the model today is one HTTP container per app, and a worker: line in your Procfile logs a warning while the web container deploys normally.

Connecting to MySQL#

Your app almost certainly needs a database, and this is the part most worth reading before your first deploy, because the failure mode is misleading.

Containers run on the podman bridge network. From MySQL’s point of view they are external clients somewhere in 10.88.x.x , not localhost. A local MySQL user will not work no matter how correct the password is. What you need is an external user in the MySQL manager, with 10.88.0.0/16 whitelisted. That exact CIDR is the one /16 our validator accepts, precisely because it is the container bridge range. The same mechanics apply as when you allow remote MySQL connections from anywhere else.

Then set the connection details as environment variables on the application. We inject MYSQL_HOST=mysql-host and MYSQL_PORT=3306 as defaults only. If you set either one yourself, your value wins, which means pointing the app at PlanetScale, RDS, Supabase, or your own VPS is just a matter of setting DATABASE_URL and ignoring ours.

External MySQL users require SSL, and this is where people lose an afternoon. If TLS does not happen, MySQL rejects the connection with errno 1045, which renders as Access denied for user (using password: YES) . It looks exactly like a wrong password. It is not. The fixes per driver:

  • mysql2 in Node: ssl: { rejectUnauthorized: false, minVersion: 'TLSv1.2' } . The minVersion is not optional. Without it mysql2 sometimes skips TLS entirely even though you passed an ssl object.
  • Prisma: append ?sslaccept=accept_invalid_certs to your DATABASE_URL .
  • Rails: ssl_mode: required in the adapter block of config/database.yml . Rails runs db:prepare before Puma binds, so a missing ssl_mode never surfaces as a database error at all. The container exits during boot and the deploy reports a health check timeout, three steps away from the real cause.
  • Django: 'OPTIONS': {'ssl_mode': 'REQUIRED'} in the DATABASES entry. Django migrates during the image build, so this one fails the build instead, which is friendlier.

Rollbacks and the safety net#

Every build leaves an artifact, and promoting an old artifact back to current is a one-click rollback. No revert commit, no rebuild, no waiting.

There is one asymmetry worth knowing. Rolling back a container app does not roll back your environment variables, because containers read them at start time, not at build time. An older build runs its own code against whatever variables are set right now. Static apps behave the opposite way, since their values were baked into the bundle at build time. If a bad deploy included a variable change, change the variable back before you promote.

Retention differs by type too. Static builds are kept indefinitely and you delete them yourself. Container builds keep the five most recent successful artifacts per app, with the currently promoted one always protected regardless of age. The build log lives with the artifact, so copy anything you want to keep before an old build ages out. Failed and cancelled builds stick around for seven days so you can review them, then get swept.

The Files tab rounds this out: a read-only browser into any build we still hold, so you can confirm what actually shipped without cloning. For container builds that is the contents of /app inside the image. Env files, keys, and credentials are hidden from the listing, and a Compare view shows which files were added, removed, or changed between two builds.

Settings covers the rest. Environment variables can be marked secret, which seals the value write-only so it is never returned to the panel again, not even to you, with the audit log redacting it on every change. Email notifications and signed webhooks report build outcomes, with automatic retries and a delivery log. And automatic cache flushing on successful deploy is on by default for new apps, so a fresh deploy serves fresh bytes.

If you build with an AI coding agent#

A growing number of these apps are not typed by hand any more. They are scaffolded by Claude Code, Cursor, Copilot, or something similar, and the agent makes a hundred small structural decisions before a human ever looks at the repository. Most of those decisions are fine. A few of them are exactly the ones that break a deploy: hardcoding a port, binding to localhost, leaving Gemfile.lock out of the commit, assuming the database is reachable at 127.0.0.1.

So we wrote the contract down in a form an agent can read. There is a machine-readable build guide at hostney.com/build-guide.txt , linked from our llms.txt so agents that look for a site index will find it on their own. It is plain text, and it states the rules directly: bind 0.0.0.0 on $PORT , what we detect and from which manifest file, how runtime versions are pinned, which environment variables each framework requires before its first deploy, how to reach the database, and a short worked example per runtime. It ends with a pre-deploy checklist and an explicit list of what we do not support, so an agent does not spend an afternoon trying to make a Go binary deploy as an application.

The practical use is to point your agent at it before it starts. “Read https://www.hostney.com/build-guide.txt and build this so it deploys on Hostney” is usually the whole instruction. The same content is on hostney.com/build-guide in a human-readable layout, generated from the same source, so the two cannot drift apart.

This is worth doing even if you write every line yourself. The build guide is the shortest accurate description of the platform contract we have, and reading it start to finish takes a couple of minutes.

Trying it#

All of this is on the 14-day free trial. No credit card, no commitment, and nothing to cancel if you walk away.

The part that used to make trials annoying was DNS. You had to own a domain, point it at us, and wait for propagation before you could see anything work. You do not any more. Every account can claim a free starter hostname, a permanent something.hostney.cloud address with SSL already in place, straight from the Add domain page. It does not count against your plan’s domain or subdomain allowance, and it gives you a working URL to deploy to in seconds. Connect a repository to it, push, and watch the build log.

Everything in this post is included. The trial comes with a server-rendered application slot, so a Rails app with real migrations, a FastAPI service talking to MySQL, or a Next.js site running as a live container are all things you can stand up and actually run, not just read about. Static and SPA deployments do not consume that slot, so you can put a React front end on one subdomain and your API container on another and test the whole two-piece shape in an afternoon.

A sensible first fourteen days looks something like this. Claim the starter hostname and connect a repository to it. Watch the first build fail on something small, because the first one usually does, and see whether the build log actually tells you why. Fix it, push, and time how long it takes to go live. Create an external MySQL user, whitelist the container bridge, and get your app talking to a database. Then break something on purpose and roll back, because the day you need that button is not the day you want to be learning where it is.

If you are still working out which plan fits afterwards, our guide to what actually matters when choosing hosting is a reasonable place to start. Pro doubles the application slots; the rest of the difference is memory, storage, and how many subdomains you get. If you already know what you want to deploy, connect the repository and let the build log tell you the rest.