The best Elixir and Phoenix hosting platforms in 2026
Most Elixir hosting roundups list platforms that can run a binary, which is all of them. A `mix release` produces a self-contained tarball with the Erlang runtime baked in; starting it somewhere is genuinely the easy part. The differences that decide whether you're happy in six months are elsewhere: whether a LiveView socket survives an hour without the proxy calling it idle, whether two nodes can find each other, and whether a deploy drains connections or just shoots the node.
This is written for someone who already knows what a supervision tree is and is deciding where to put one. I build PandaStack, which appears near the bottom — flagged where it comes up, and described with its trade-offs rather than its brochure copy.
What makes Phoenix hosting different
The BEAM is an operating system that thinks it's a runtime. It schedules its own processes, manages memory per-process, runs its own distribution protocol between nodes, and expects to be alive long enough for all of that to matter. Nearly every awkward Phoenix deployment story comes from putting that runtime somewhere designed for stateless, request-scoped work.
LiveView and Channels hold connections open
A LiveView page isn't a page. It's an HTTP render followed by a WebSocket that stays connected for as long as the tab is open, with a stateful server process holding that session's assigns. Channels are the same shape. So the first question for any platform is about its proxy, not its runtime: are WebSockets supported end to end, and what are the maximum connection duration and idle timeout? A proxy that caps requests at some number of seconds will cheerfully terminate LiveView sockets mid-session.
The BEAM wants a machine, not an invocation
Supervision trees, GenServers holding state, ETS tables, Oban or Quantum running scheduled work, a Registry, a cache — a mature Phoenix app keeps a lot of its behaviour in processes that belong to no request. Freeze the container between invocations and all of it stops: timers don't fire, `handle_info` never runs, and the GenServer flushing a buffer every five seconds quietly stops flushing.
That's why 'Elixir on serverless functions' reads as a category error rather than a trade-off. What you get is a runtime engineered to run millions of lightweight processes concurrently, executing one request at a time, paying its startup cost repeatedly, with nowhere for its concurrency to live. If you're weighing that move, the general shape of it is covered at /blog/how-to-move-from-serverless-functions-to-a-long-running-server.
The flip side is a real advantage: because concurrency lives inside one OS process, Phoenix scales a single machine unusually well. Where Rails or Django fans out into workers-per-core and multiplies its memory footprint, one BEAM node with enough RAM holds a lot of concurrent connections. You usually want fewer, bigger machines — which changes what counts as a good plan when comparing platforms.
Clustering and distributed Erlang
Join two or more BEAM nodes into a cluster and `Phoenix.PubSub` broadcasts cross-node for free, Horde or `:global` can place named processes anywhere, and `Phoenix.Presence` gives a consistent view of who's online. It's one of the strongest reasons to be on the BEAM at all, and it's exactly the part most PaaS networking makes awkward.
Distributed Erlang wants three things ordinary web hosting doesn't provide by default: nodes addressable by a stable name resolving to a routable private address, a discovery mechanism (in practice a DNS record listing siblings), and open TCP between nodes for epmd plus the distribution ports it hands out. If your platform gives every instance a random hostname behind a load balancer, no private network, and no way to enumerate siblings, clustering ranges from painful to impossible.
The honest caveat: you may not need it. `Phoenix.PubSub` has a Postgres adapter that works fine across unconnected nodes. Reach for clustering when you actually want distribution — presence, global singletons, shared in-memory state — not because it's the Elixir thing to do.
# config/runtime.exs — libcluster with DNS-based discovery.
# The platform gives you a private DNS name that resolves to every instance;
# DNSPoll turns those A records into node names and connects them.
config :libcluster,
topologies: [
app: [
strategy: Cluster.Strategy.DNSPoll,
config: [
polling_interval: 5_000,
# e.g. "myapp.internal" — must be a PRIVATE record listing siblings
query: System.get_env("CLUSTER_QUERY") || "myapp.internal",
node_basename: System.get_env("RELEASE_NAME") || "myapp"
]
]
]
# rel/env.sh.eex — how each node gets a stable, routable name. Without this
# the release starts in :none distribution mode and Node.connect/1 just
# returns false, with nothing in the logs that says "not distributed".
#
# export RELEASE_DISTRIBUTION=name # sname only works on one host
# export RELEASE_NODE="myapp@${PRIVATE_IP}"
# # Pin the distribution port range if the platform firewalls by port:
# export ERL_AFLAGS="-kernel inet_dist_listen_min 9100 inet_dist_listen_max 9155"
#
# RELEASE_COOKIE must be identical on every node and should come from a
# secret, not the random cookie mix release generated at build time. A
# mismatched cookie refuses the connection without a useful error.Releases, runtime.exs, and version pinning
Modern Elixir deployment is `mix release`: compile with `MIX_ENV=prod`, bundle the compiled beams plus the Erlang runtime into a directory, ship it, run `bin/myapp start`. No Elixir on the target box, no `mix` at runtime. It deletes an entire class of works-on-my-machine problems.
The concept that trips up everybody at least once is `config/config.exs` versus `config/runtime.exs`. Anything in `config.exs` and its imported `prod.exs` is evaluated at build time and frozen into the release — read an environment variable there and you capture the build machine's value, which is usually `nil`. `runtime.exs` is evaluated when the release boots on the machine that will serve traffic, and that's where `DATABASE_URL`, `SECRET_KEY_BASE`, `PHX_HOST`, and `PORT` belong. A freshly generated Phoenix app gets this right; a five-year-old app that predates the change often doesn't.
Pin Elixir and Erlang/OTP both. They're two versions, not one, and an Elixir build targets a specific OTP major — mismatching them produces compile errors that read like library bugs. `.tool-versions` is read by asdf and by mise, which is what most modern build pipelines reach for. Then check what your dependencies need from the build image: anything with a NIF or a port — `bcrypt_elixir`, `exqlite`, image libraries — needs a C toolchain, and Rustler-based deps need Rust. A minimal build container that works for a plain app fails the moment you add password hashing.
Ecto, pool size, and where the database lives
Ecto's pool is explicit and small by design. Because concurrency comes from BEAM processes checking connections in and out rather than one connection per thread, a Phoenix node needs far fewer database connections than an equivalent Rails or Django deployment. The classic mistake is enlarging the pool because traffic grew: the pool is a queue, and the fix for saturation is usually faster queries, not more sockets to Postgres.
Multiply pool size by node count and compare against the database's connection limit before you scale out. A transaction-mode pooler helps if that number gets uncomfortable, with the usual caveat that it breaks session-scoped features and requires disabling prepared statement caching in Ecto — trade-offs at /blog/how-to-connect-to-postgres-with-a-connection-pooler. And keep the database in the same region as the nodes: LiveView makes latency visible in a way request-response apps hide, because a single keystroke can become a round trip.
Zero-downtime deploys and draining sockets
Dispose of the myth first: hot code upgrades are real, work, and are used by almost nobody deploying Phoenix. Relups need hand-maintained appup files, they get fragile as soon as dependencies change, and no mainstream platform's deploy model accommodates them. In practice Elixir deploys like everything else — build a new release, start it, shift traffic, stop the old one.
What matters is the drain. Every open LiveView and Channel on the outgoing node has to go somewhere. A decent grace period lets the endpoint stop accepting new connections while existing ones finish or migrate. A deploy that SIGKILLs the old node drops every socket at once and your whole connected user base reconnects simultaneously — a self-inflicted thundering herd against a node that's still warming up. Ask what the shutdown signal is, how long the grace period is, and whether it's configurable; then match your supervisor `:shutdown` timeouts, and give Oban jobs time to finish.
How to actually deploy a Phoenix release
Whichever platform you pick, the sequence is the same. The only real difference is which steps the platform runs for you.
# 1. Pin BOTH runtimes in the repo. asdf and mise read this file.
cat > .tool-versions <<'EOF'
erlang 27.3
elixir 1.18.3-otp-27
EOF
# 2. Build. Everything here happens at BUILD time, on the build machine.
export MIX_ENV=prod
mix deps.get --only prod
mix compile
mix assets.deploy # esbuild + tailwind + mix phx.digest
mix release # -> _build/prod/rel/myapp
# 3. Migrate ONCE, before traffic shifts. A release has no mix task, so
# Phoenix generates a release module for exactly this:
_build/prod/rel/myapp/bin/myapp eval 'MyApp.Release.migrate()'
# 4. Run. These are read at BOOT by config/runtime.exs, not baked in.
export SECRET_KEY_BASE="$(mix phx.gen.secret)" # store it, don't regenerate
export DATABASE_URL="postgres://user:pass@db.internal:5432/myapp"
export PHX_HOST="app.example.com"
export PHX_SERVER=1 # without this the release starts and serves nothing
export PORT=4000
_build/prod/rel/myapp/bin/myapp start
# Once it's up: attach a remote IEx shell to the running node.
_build/prod/rel/myapp/bin/myapp remoteOne networking detail bites on migration. A generated Phoenix app binds the IPv6 any-address, `{0, 0, 0, 0, 0, 0, 0, 0}`, which is correct on platforms with private IPv6 networking and wrong on IPv4-only ones — producing a failing health check with entirely clean application logs. Likewise `socket_options: [:inet6]` on the Repo is required on some networks and breaks the connection on others. Both are one-line fixes that look like the platform being broken until you know they exist.
The realistic options
Fly.io
The strongest historical Elixir story of any general-purpose platform, and not by accident — the company invested in Elixir directly and built tooling that treats clustering as a normal thing to want. `fly launch` recognises a Phoenix app, private networking with internal DNS makes libcluster's DNS strategies straightforward, and long-lived connections are ordinary traffic rather than an exception. The trade-offs are what you'd expect from a platform handing you real machines: you're closer to the infrastructure than on a classic PaaS, multi-region introduces write-forwarding and replica-lag decisions a single-region app never faces, and their Postgres has historically leaned toward 'we run the primitives, you own the cluster' — check its current state rather than assuming.
Gigalixir
The Elixir specialist: a platform built by and for Elixir people, where clustering, remote observer access, and BEAM-native operational concerns are the product rather than a workaround. If you want a host where nobody needs convincing that distributed Erlang is a reasonable request, this is the most direct answer, and it has historically supported things Elixir teams miss elsewhere — including hot upgrades, for the few who genuinely want them. The trade-off of a specialist is breadth: fewer adjacent managed services, a smaller community, and the risk of depending on a niche vendor for something load-bearing. Verify the current feature set directly.
Render
Solid and unexciting in the best sense: a Heroku-shaped container platform with long-lived web services, managed Postgres, background workers and cron as first-class objects, and a repo-level blueprint describing the whole thing. WebSockets work, the process stays alive, and a pre-deploy command is the right home for `Release.migrate()`. For a single-node Phoenix app with a database — which is most Phoenix apps — this is a good answer. The caveat is clustering: you get whatever private networking and service discovery the vendor chooses to expose, so check their current docs before planning on Presence or Horde.
Railway
The nicest first hour on this list. Connect a repo, get a service; add Postgres from a menu; variables reference each other across services so `DATABASE_URL` simply appears; per-branch environments make review apps genuinely pleasant, and their build system handles Elixir without a Dockerfile in the common case. The trade-offs are the usual convenience-first ones: less control over the machine, usage-based pricing that rewards knowing what your app consumes, and clustering support to verify rather than assume. Great for velocity; re-examine when the app becomes load-bearing revenue.
Heroku
Elixir runs on Heroku via a community buildpack and has for years. You get the twelve-factor model everyone else copied, a release phase that's the correct place for migrations, and managed Postgres that is genuinely well-operated. The caveats are well known in the Elixir community: the dyno network has historically not supported the node-to-node connectivity distributed Erlang needs, so clustering is what you give up; router-level timeouts are worth confirming against a real LiveView session; and a community buildpack is a different level of platform commitment than a supported runtime. Fine if Heroku is already your company's platform and you don't need a cluster.
DigitalOcean App Platform
The straightforward managed-container option inside a cloud that also sells droplets, managed Postgres, and object storage, with pricing that's easy to reason about. Deploy a container, get a long-running service, attach a managed database in the same region, receive one bill. The caveat is that it's general-purpose with no Elixir awareness: you maintain your own Dockerfile, and clustering depends on what their app-level private networking currently offers. A good fit if you're already a DigitalOcean shop, or want the escape hatch of dropping down to plain droplets without changing vendors.
Google Cloud Run (and similar request-scoped platforms)
This one deserves a specific caveat rather than a blanket dismissal, because Cloud Run has moved a long way from its original request-scoped model: it runs containers, supports WebSockets, and has configurations where instances stay alive rather than throttled between requests. Phoenix can genuinely run there, and for a low-traffic internal app it's a reasonable use of infrastructure you already have.
Three things to verify, all historically real constraints: the maximum request duration, which also bounds how long a WebSocket may live; whether CPU is allocated outside request handling, because a throttled instance won't run your GenServers, timers, or Oban jobs; and how instances are addressed, since autoscaled, individually-unaddressable instances are the opposite of what distributed Erlang wants. Treat 'my LiveView reconnects every N minutes' as the expected symptom of getting the first one wrong. Reasonable if organisational gravity puts you on Google Cloud and you accept a single node — and read the current limits yourself, because this is precisely where a vendor's constraints change between writing and reading.
Self-managed VMs and Kubernetes
Two flavours of owning it. A couple of VMs running your release under systemd is genuinely excellent for Elixir: releases are self-contained, systemd handles restarts, a private network between two boxes makes clustering trivial, and one person can hold the whole thing in their head. The BEAM's operational model — supervision, remote shells, `:observer` — rewards it more than most runtimes do.
Kubernetes is the other end, and it has the best-documented clustering story that exists, because libcluster ships strategies that discover pods via the API or a headless service. It also brings the full operational surface: ingress configured for WebSockets, `terminationGracePeriodSeconds` long enough to drain sockets, pod disruption budgets, and a platform team. Pick VMs for simplicity and control; pick Kubernetes if you're already on it, not as a way to get to it.
PandaStack
Mine, so apply the appropriate discount. PandaStack is git-driven app hosting on Firecracker microVMs: connect a repo, push to deploy, and each deploy builds into a fresh microVM that takes traffic once health checks pass. There's no Dockerfile — runtime versions come from the idiomatic files already in your repo, so a `.tool-versions` pinning Erlang and Elixir is read by mise at build time. Your app binds `$PORT`, managed Postgres 16 is a create-and-attach environment variable away (30–90 seconds to provision), and because each app is a real long-running VM rather than a request-scoped container, LiveView sockets, GenServers, and Oban behave the way the BEAM expects.
# The repo pins both runtimes; mise reads .tool-versions at build time.
# erlang 27.3
# elixir 1.18.3-otp-27
pandastack apps create --name myapp \
--git-url https://github.com/acme/myapp \
--build-cmd 'mix deps.get --only prod && MIX_ENV=prod mix assets.deploy && MIX_ENV=prod mix release' \
--start-cmd '_build/prod/rel/myapp/bin/myapp start'
# Managed Postgres, then hand the URL to the release as env —
# runtime.exs reads it at boot, which is the entire point of runtime.exs.
pandastack db create --label myapp-prod
pandastack apps env set myapp \
DATABASE_URL="postgres://pandastack:...@<id>.db.pandastack.ai:5432/pandastack" \
SECRET_KEY_BASE="<output of mix phx.gen.secret>" \
PHX_HOST="myapp.example.com" \
PHX_SERVER=1
# Migrations as a discrete step before traffic shifts — not in the start command.
pandastack apps exec myapp -- _build/prod/rel/myapp/bin/myapp eval 'MyApp.Release.migrate()'The substrate is the genuinely different part. Machines boot by restoring a snapshot rather than booting cold — roughly 179ms at p50 and 203ms at p99 end to end, with a ~49ms restore step, against about 3s for a true first cold boot. That makes scale-to-zero honest rather than aspirational: an idle app can be nothing at all and still wake fast enough that nobody files a ticket, which matters for staging environments and internal tools nobody touches midweek. Forking a running machine takes 400–750ms on the same host, or 1.2–3.5s across hosts, which is what makes an environment per branch cheap.
The honest trade-offs. Multi-node clustering isn't a one-flag feature here the way it is on Fly or Kubernetes — if your architecture depends on Presence or Horde, verify the networking story before committing rather than after. Scale-to-zero and long-lived WebSockets are in tension by definition, since an app holding open sockets is never idle, so the economics only pay off for workloads that genuinely go quiet. And it's younger than everything above it, with a smaller ecosystem. Worth it if per-app kernel isolation, real scale-to-zero, or cheap per-branch environments are things you actually need; skip it if a conventional PaaS already fits.
The one-line version
- Fly.io — model: VMs you control, with private networking and internal DNS; best for: clustered Phoenix, LiveView at scale, multi-region apps; caveat: closer to infrastructure than a classic PaaS, and multi-region is real distributed-systems work.
- Gigalixir — model: Elixir-specialist PaaS built around BEAM operations; best for: teams who want clustering and remote shells treated as normal; caveat: a niche vendor with a smaller surrounding ecosystem — verify the current feature set yourself.
- Render — model: Heroku-shaped managed containers with first-class workers and cron; best for: a single-node Phoenix app with managed Postgres and no drama; caveat: clustering depends on their current private networking.
- Railway — model: repo-connected services with per-branch environments; best for: fastest path from git to a running LiveView app; caveat: less machine control, usage-based pricing, clustering to verify rather than assume.
- Heroku — model: the original twelve-factor dyno platform, Elixir via community buildpack; best for: adding Phoenix to an estate already living there; caveat: dyno networking has historically ruled out distributed Erlang, and Elixir isn't an official runtime.
- DigitalOcean App Platform — model: managed containers beside droplets, Postgres, and object storage; best for: one vendor, predictable billing, an escape hatch to plain VMs; caveat: no Elixir awareness — you own the Dockerfile.
- Google Cloud Run — model: managed containers with request-oriented scaling; best for: low-traffic Phoenix inside an existing GCP footprint; caveat: check max request duration and whether CPU runs outside requests, and expect clustering to be impractical.
- Self-managed VMs / Kubernetes — model: your machines, your systemd units or your pods; best for: full control, trivial clustering on a private network, best economics at scale; caveat: you own patching, TLS, drain config, backups, and the pager.
- PandaStack — model: git-driven deploys onto Firecracker microVMs, runtime pinned from .tool-versions; best for: per-app kernel isolation, genuine scale-to-zero, cheap environment-per-branch; caveat: clustering isn't a one-flag feature, and it's a younger platform.
Pick by situation
- If you're shipping a single-node Phoenix app with Postgres and want it boring → Render, or DigitalOcean App Platform if you're already there.
- If clustering, Presence, or Horde is core to the product → Fly.io, Gigalixir, or Kubernetes, in that order of effort.
- If you want an Elixir-native host where nobody questions distributed Erlang → Gigalixir.
- If you're optimising for developer velocity and per-branch preview environments → Railway.
- If your company already standardised on Heroku and you don't need a cluster → Heroku, with the router timeout verified against a real LiveView session.
- If organisational gravity puts you on GCP → Cloud Run for a single node, once you've checked request duration and CPU-outside-of-requests; GKE if you need more than one.
- If you have someone who enjoys owning servers → two VMs, systemd, and a private network. It's a genuinely excellent Elixir deployment and it costs the least.
- If you run many isolated tenant apps, or want staging environments that cost nothing while idle → PandaStack, mine, for the microVM isolation and scale-to-zero rather than for clustering.
- If someone suggests running Phoenix on serverless functions → suggest a long-running process instead, and see /blog/websocket-apps-persistent-connections-hosting for why.
The short version
For most Phoenix apps the platform matters less than five checks: WebSockets survive an hour without the proxy killing them; the process stays alive and CPU-scheduled between requests so the supervision tree actually runs; runtime config lives in `runtime.exs` and `PHX_SERVER` is set; migrations run once in a pre-traffic step; and deploys drain sockets rather than severing them. Add private networking and stable node names to that list only if you genuinely need a cluster. Get those right and Fly, Gigalixir, Render, Railway, Heroku, DigitalOcean, a pair of VMs, and PandaStack all work. Get them wrong and you'll spend a month convinced LiveView is flaky, when what's flaky is the load balancer.
Frequently asked questions
Can you run Phoenix LiveView on serverless?
Technically sometimes, structurally no. LiveView holds a WebSocket open for the life of the page with a stateful server process behind it, so any platform that caps request duration, freezes the container between invocations, or routes each connection to a fresh instance will terminate sessions mid-use. The client reconnects and remounts, so you don't see errors — you see state vanishing and users reporting that forms reset themselves. Container platforms that keep a genuinely long-running process, including some Cloud Run configurations, are a different story and can work. Function-per-request platforms are not the shape LiveView was designed for.
Do you need clustering to run Phoenix in production?
Usually not. A single well-sized BEAM node handles a lot of concurrent connections, and Phoenix.PubSub has a Postgres adapter that broadcasts across unconnected nodes perfectly well, which covers the most common reason people think they need a cluster. Reach for distributed Erlang when you want the genuinely distributed features: Phoenix.Presence with a consistent cross-node view, globally unique processes via Horde or :global, or shared in-memory caches. Those are real reasons. Clustering because it's the Elixir thing to do adds networking requirements, a cookie to manage, and a class of split-brain problems you otherwise never meet.
Can you deploy Phoenix without Docker?
Yes, and it's one of Elixir's better stories. A mix release bundles your compiled application together with the Erlang runtime into a self-contained directory, so the target machine needs no Elixir, no Erlang, and no mix — just the binary and a compatible libc. You can ship that tarball to a VM and run it under systemd, or use a platform that builds from your repo and reads runtime versions from .tool-versions. Docker still helps when you need a reproducible build environment for native dependencies, or when your platform only accepts containers, but it is not a requirement of the language.
How much memory does a Phoenix app need?
Less than an equivalent Rails or Django deployment at the same concurrency, because BEAM processes are lightweight and you run one OS process rather than a worker per core each carrying a copy of the app. Beyond that, measure rather than guess: memory scales with the number of live processes and what they hold, so a LiveView app with large assigns per session behaves very differently from a stateless JSON API. Watch binary memory and ETS growth specifically, since both live outside per-process heaps. Build the habit of checking :erlang.memory/0 under real load and sizing the machine from what you see.
Are Erlang hot code upgrades a reason to pick a hosting platform?
For almost everyone, no. Relups genuinely work and are legitimately impressive, but they require hand-maintained appup files, they get fragile as soon as dependencies change, and the tooling assumes a deployment model modern platforms don't use. The overwhelming majority of production Phoenix deploys build a new release, start it, shift traffic, and stop the old one — exactly like every other stack. Judge platforms instead on whether they drain open sockets gracefully during that swap, because that is the part your users actually feel. Treat hot upgrades as a specialist capability for specialist systems, not a hosting checkbox.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.