RE:NODE

Deploy a Rust app with Cargo: build, port, env and GitHub

By RE:NODE Team
17 min read


To deploy a Rust web service on a Cargo app server you need four things right: a repository with Cargo.toml and a committed Cargo.lock at its root, a program that listens on 0.0.0.0 and the port in the SERVER_PORT environment variable, a line printed to standard output once it is listening (the panel uses it to know the server has started), and a plan with enough memory to compile your dependency tree, which is a very different number from what the finished binary needs. Get those four right and the rest is a GitHub connection and a habit of reading the console. This is the Rust language, by the way - if you came here for the survival game, you want the Rust dedicated server guide instead.

This post walks through it in the order you will hit it: what happens when the server starts, preparing the repository, the port and the start-detection line, how long a build takes and how much RAM it eats on each tier, configuration and secrets, deploying from GitHub, and reading the logs when it goes wrong. For deeper tuning of the service itself - tokio worker threads, allocator behaviour, graceful shutdown, connection pools - we have a separate post on deploying a Rust web service with axum. This one is about getting from a repository to a running, reachable process.

What happens when a Cargo server starts#

A Rust (Cargo) server on RE:NODE is a container with the Rust toolchain in it and your project in its files. It does not run a prebuilt image of your app. On every start it builds and runs your code with cargo, which has a consequence worth absorbing early: a start is a compile. A restart is a compile. A deploy is a compile. Cargo is good at not redoing work it has already done, so most of those compiles are short, but the first one never is.

If you connect a GitHub repository, the order on each start is: fetch the branch you chose, then build, then run. The diagram is the whole lifecycle.

HTTPSHTTPGitHub repoyour branchVisitorsFetch on startpull or deploy on pushProxy slotHTTPS for your domaincargo builduses target/ cacheYour binarylistens on SERVER_PORT
One start of a Cargo app server

A brand-new server is not empty. Until you deploy, it holds a tiny standard-library Rust project - a Cargo.toml and a src/main.rs that serves a "what to do next" page on its address. It is there so the first start shows something working rather than a crash, and it only ever gets written into an empty server; it never overwrites files of yours. Open the address once to confirm the server answers, then replace the project with your own.

The startup command itself is visible on the Startup tab. Read it once, so you know whether it builds in release mode and which binary it runs - the sections below assume a release build, which is the only kind worth deploying.

Prepare the repository#

Cargo needs very little, but it needs it in the right place. The server builds from the root of what it fetched, so Cargo.toml must be at the top of the repository, not in a backend/ folder. If your service lives in a workspace, put the workspace Cargo.toml at the root and make sure the binary you want is the one that runs by default.

The checklist:

  1. Commit `Cargo.lock`. For a binary it belongs in the repository. Without it every build resolves dependencies afresh, and two deploys of the same commit can produce different programs.
  2. Ignore `target/`. It is the build cache, often gigabytes, and it is machine-specific. It goes in .gitignore, never in a commit.
  3. Pick the default binary. If the package has more than one binary (src/bin/*.rs plus src/main.rs), cargo run refuses to guess. Set default-run in [package] so it does not have to.
  4. Never commit `.env`. Secrets go on the server, not in Git. More on that below.
  5. Pin the toolchain if you depend on a recent feature. A rust-version in Cargo.toml makes an old compiler fail with a clear message instead of a page of confusing errors.

A minimal Cargo.toml for an axum service, with a release profile tuned for building on a small server rather than for the last few percent of speed:

Cargo.toml
[package]name = "api"version = "0.1.0"edition = "2021"default-run = "api"[dependencies]axum = "0.8"tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }[profile.release]strip = "symbols"debug = false

The version numbers are examples; use whatever cargo add gives you on the day. Note what is not in that profile: no lto = true, no codegen-units = 1. Both make the binary a little faster and smaller and the build considerably slower and hungrier. On a 1 GB plan they can turn a build that finishes into one that does not. Add them later, once you know the build fits.

And the .gitignore that every Rust repository should start with:

.gitignore
/target.env

Bind to the allocated port#

Your plan comes with one allocated port. The panel puts it in the environment as SERVER_PORT, and your program has to listen on exactly that port on 0.0.0.0. Two mistakes cover most "it runs but I cannot reach it" reports:

  • Listening on `127.0.0.1` or `localhost`. Inside a container that means "only reachable from inside this container". The log says it is listening, and nothing outside can connect.
  • Hard-coding a port. Tutorials love 3000 and 8080. Your allocated port is neither, unless by coincidence.

Read the port from the environment and fail loudly if it is missing, rather than quietly falling back to a port nobody forwards:

src/main.rs (axum)
use axum::{routing::get, Router};use tokio::net::TcpListener;#[tokio::main]async fn main() {    tracing_subscriber::fmt()        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())        .init();    let port: u16 = std::env::var("SERVER_PORT")        .expect("SERVER_PORT is not set")        .parse()        .expect("SERVER_PORT is not a number");    let app = Router::new().route("/health", get(|| async { "ok" }));    let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();    println!("listening on 0.0.0.0:{port}");    axum::serve(listener, app).await.unwrap();}

The actix-web equivalent is the same idea in a different builder:

src/main.rs (actix-web)
#[actix_web::main]async fn main() -> std::io::Result<()> {    let port: u16 = std::env::var("SERVER_PORT").expect("SERVER_PORT").parse().unwrap();    let server = HttpServer::new(|| App::new().route("/health", web::get().to(health)))        .workers(2)        .bind(("0.0.0.0", port))?;    println!("listening on 0.0.0.0:{port}");    server.run().await}

The .workers(2) matters on a small plan. actix-web starts one worker per logical CPU it can see by default, and a container limited to half a core may still see the whole machine's core count. Two workers is plenty on the lower tiers.

If you want the same code to run on your laptop, keep a fallback for local development only, behind an explicit variable or a cfg(debug_assertions) check, so a release build on the server never silently binds the wrong port.

The start-detection line#

This is the step that catches almost everyone once. The panel decides that a server has finished starting by watching the console for a particular piece of text. Until it sees that text, the server shows as "Starting", however well your service is actually running. On a Cargo server you set that text yourself on the Startup tab, and it must be a line your program really prints.

The simplest arrangement is the one in the examples above: print one plain, stable line after the listener is bound, and use a fragment of it as the start-detection text.

code
listening on 0.0.0.0:

A few details make this reliable:

  • Print it after `bind` succeeds, not at the top of main. Then "started" means "accepting connections", which is what you want the panel to report.
  • Use `println!`, not only `tracing`. Rust's standard output is line-buffered, so a println! reaches the console the moment it runs. A tracing line works too, but its format depends on your subscriber config and an innocent change to the format (timestamps, colours, JSON output) can stop the text matching.
  • Do not include the port number in the detection text unless you are sure it will never change. The fragment listening on 0.0.0.0: survives a port change; the full line does not.
  • Keep it out of build output. Cargo prints Compiling and Finished lines during the build. Never use text that cargo itself prints, or the panel will call the server started before your program has run.

Build time and memory on each plan#

Here is the uncomfortable part of Rust hosting. The program you deploy idles in tens of megabytes. The compiler that builds it can need well over a gigabyte, for minutes, with the peak at the final link. The plan has to fit the build, not just the binary.

Rust (Cargo) plans on RE:NODE start at $4 a month and run in five tiers:

PlanRAM / disk / CPUBuilding on the server
Starter1 GB / 5 GB / 0.5 vCPUSmall services, one job at a time
Standard2 GB / 10 GB / 1 vCPUA typical axum or actix service
Advanced4 GB / 20 GB / 1.5 vCPULarge trees, sqlx, some LTO
Pro6 GB / 35 GB / 2 vCPUHeavy trees, LTO without worry
Premium8 GB / 50 GB / 3 vCPUWorkspaces, several binaries

The right-hand column is our rule of thumb, not a guarantee. Build cost depends on how many crates are in Cargo.lock, how macro-heavy they are (serde derive, sqlx, anything with procedural macros), and whether the C dependencies need compiling. As a rough guide from experience: a small service with a few dozen crates does its first release build in a few minutes on one core; a typical web service with two or three hundred crates takes longer, and on half a core it can be ten minutes or more. Later builds that only touch your own code are much faster, because the compiled dependencies sit in target/ and are reused.

What happens when the build is too big for the plan matters, because it does not look like an error. On RE:NODE, a container that reaches its memory limit is stopped by the kernel and restarted clean rather than being allowed to swap. A build that runs out of memory therefore shows up as the console stopping mid-Compiling and the server starting again, often into the same wall. If you see that pattern, it is memory, and there are three fixes in order of preference:

  1. Build one crate at a time. Cargo compiles crates in parallel by default, and each parallel job holds its own memory. Setting jobs to one cuts the peak sharply at the price of wall-clock time.
  2. Take LTO and `codegen-units = 1` out of the release profile. They are the most expensive settings in the link step.
  3. Move up a tier. If the service needs three hundred crates, it needs a plan that can compile them. Changing plan does not rebuild the server.

The first fix can live in the repository, so it travels with the code and applies to every build on the server:

.cargo/config.toml
[build]jobs = 1

Disk deserves the same attention. A target/ directory for a middling web service is commonly one to three gigabytes, and if both debug and release artefacts end up in it, more. The Starter plan's 5 GB disk can fill with build cache alone. If you ever ran a debug build on the server, deleting target/debug in the file manager is an easy gigabyte. Do not delete all of target/ as a habit - that throws away the dependency cache and the next start compiles everything again.

There is a legitimate alternative for very small plans: build the release binary in CI or on your own machine for x86_64-unknown-linux-gnu (or -musl for a static binary), upload it over SFTP, and run that. You give up the convenience of building from source on every start, and it only works if the startup command runs your binary rather than cargo, so check the Startup tab before you try it.

Environment variables and secrets#

Configuration goes in environment variables, read once at start-up, and the program refuses to start when one is missing. That way a forgotten database URL stops the deploy at the console instead of failing at three in the morning on the first request that needs it.

There are two places to put values on a Cargo server:

  • The Startup tab. It shows the server's variables, including SERVER_PORT. Use it for the variables the panel exposes there.
  • A `.env` file in the server's files. Create it with the file manager or upload it over SFTP, and load it with the dotenvy crate at the top of main. It never goes in Git.
.env
DATABASE_URL=postgres://app_user:generated-password@db-host:5432/appRUST_LOG=info,tower_http=info,sqlx=warnRUST_BACKTRACE=1APP_BASE_URL=https://api.example.com
src/main.rs
fn main() {    // Loads .env if it exists; real environment variables win.    let _ = dotenvy::dotenv();    // ...}

dotenvy::dotenv() does not overwrite variables that are already set in the environment, so SERVER_PORT from the panel always wins over a stray value in the file. That is the behaviour you want.

If you connect the repository to GitHub, the pre-flight check on the GitHub tab reports a committed .env file by name without opening it. If it ever flags one, remove it from the repository and rotate every secret it contained, because the history still has it. The general rules - where secrets live, how to rotate them, what never to log - are in environment variables and secrets.

App plans include database slots: create one in the panel and you get a generated host, user and password to put in DATABASE_URL. If your service uses sqlx's compile-time checked queries, the build needs either a live database or prepared query data: run cargo sqlx prepare locally, commit the .sqlx directory, and set SQLX_OFFLINE=true so the server build does not try to reach a database while compiling. A separate PostgreSQL server, if you outgrow a slot, is a database hosting plan.

Deploy from GitHub#

Uploading source over SFTP works, but GitHub deploys are what make a Cargo server pleasant to live with. The panel connects through a GitHub App with short-lived tokens, so private repositories work without you pasting a personal access token anywhere. GitHub is the only provider supported.

On the server's GitHub tab you link a repository and a branch, and there are two switches:

  • Pull on every start. Each start fetches the branch before building. A restart from the panel is then also an update.
  • Deploy on push. A push to the branch restarts a running server, or starts a stopped one, which fetches and builds the new commit.

Every deploy leaves a record, so you can see which commit went out and when. Before you link, the pre-flight check reads the repository - a tree listing and a few small files, never a full clone - and lists what will break, worst first, with a fix to paste: a committed .env, binding the wrong host or port, and similar mistakes that would otherwise only show up after the first start. It checks the obvious failure modes; it does not compile your code, so a type error still surfaces in the console.

Two things are specific to Rust and worth planning for:

  • A deploy is a restart, and a restart is a build. The old process stops, the new commit compiles, and only then does the new binary start listening. For a change to your own code that is usually seconds to a minute; for a change to Cargo.lock that bumps a widely used dependency it can be several minutes, during which the service is down. Batch dependency updates rather than pushing them one at a time, and push them when nobody is relying on the service.
  • A broken build means a server that does not come up. If the new commit does not compile, there is no old binary serving in the meantime. Run cargo build --release and your tests locally or in CI before the push reaches the deploy branch, and consider deploying from a dedicated branch that only receives merges that passed.

For more on keeping that window small, zero-downtime deploys on a small server covers what is and is not achievable with one process on one port, and pre-flight checks that catch broken deploys goes through the GitHub tab's check in detail.

Logs and the console#

Everything your program writes to standard output and standard error appears in the panel console, live and unfiltered, next to graphs of memory, CPU and disk against the plan's limits. That console is the first place to look for every problem in this post: the build output, the start-detection line, panics and your own logs all land there.

For structured logging, tracing with tracing-subscriber writing to standard output is the standard choice, and the RUST_LOG variable sets what gets through:

env
RUST_LOG=info,tower_http=debug,sqlx=warn,hyper=warn

The format is a default level, then per-crate overrides. tower_http=debug with a TraceLayer on the router logs every request with its status and latency, which is the most useful single line you can add when debugging. Turn it back to info when you are done; request logs at debug level on a busy service are noise.

Set RUST_BACKTRACE=1. It costs nothing until something panics, and then it turns "thread 'tokio-runtime-worker' panicked" into a stack you can act on. If you want line numbers in those backtraces, set debug = "line-tables-only" in the release profile; it makes the binary bigger, not the process hungrier.

Watch the memory graph during the first build and the first hour of traffic. The build peak tells you whether your plan is sized right. The running process should settle at a flat line well under the limit; a line that climbs steadily under constant load is worth investigating before it meets the ceiling. Logs worth keeping covers what to keep, and for how long, once the service is in steady state.

Domain and HTTPS#

Serve plain HTTP on your allocated port and let the proxy slot handle TLS. App plans include one: point an A record for your domain at the address shown, and the certificate is issued and renewed automatically, inside a 21-day window. Your service never touches a certificate file.

Behind the proxy, the client's real address is in the X-Forwarded-For header, not in the connection, so axum's ConnectInfo gives you the proxy rather than the visitor. If you rate-limit or log by IP, read the header. Anywhere you build absolute URLs - redirects, OAuth callbacks, links in emails - use a configured base URL such as APP_BASE_URL rather than guessing from the request. What a reverse proxy does explains the rest of the header chain, and websockets behind a reverse proxy matters if you use axum::extract::ws.

Troubleshooting#

The server sits at "Starting" for ever. The start-detection text on the Startup tab does not match anything your program prints. Copy a fragment of your listening line into it and restart.

The console stops mid-`Compiling` and the server restarts. The build hit the memory limit. Set jobs = 1 in .cargo/config.toml, drop LTO from the release profile, or move up a tier.

`error: could not find Cargo.toml`. The project is in a subdirectory of the repository. Move it to the root, or put a workspace Cargo.toml there.

`cargo run` could not determine which binary to run. The package has several binaries. Add default-run to [package].

It is running but the address does not answer. It is bound to 127.0.0.1 or to a hard-coded port. Bind 0.0.0.0 and read SERVER_PORT.

`Address already in use`. Something else in the same process tree already holds the port - often a second listener you added for metrics on the same port. One plan has one allocated port; serve metrics on a route of the main router instead.

Every deploy takes as long as the first one. Something is clearing target/, or Cargo.lock is not committed and dependencies resolve differently each time. Commit the lock file and leave the cache alone.

`set DATABASE_URL to use query macros`. sqlx is trying to check queries at compile time. Commit the .sqlx directory from cargo sqlx prepare and set SQLX_OFFLINE=true.

The disk is full. Usually target/. Delete target/debug if it exists, and check that you are not building debug and release side by side.

FAQ#

Is this for the Rust game or the Rust programming language?

The language. Rust (Cargo) hosting runs programs written in Rust and built with cargo - web services, bots, workers. The survival game is a different product with its own setup, covered in our Rust dedicated server guide.

Which plan do I need for an axum service?

The service itself would run on the smallest plan. The build decides. A small service builds on 1 GB with one compile job at a time; a typical web service with a few hundred crates is far more comfortable on the 2 GB tier or above. Watch the memory graph during the first build and size from what it shows.

Why does a restart take so long?

Because every start builds the project with cargo before running it. Once dependencies are cached in target/, a restart with no code changes is mostly cargo confirming nothing needs rebuilding, and a change to your own code recompiles only your crate. A changed Cargo.lock is what makes it slow again.

Can I run something other than a web server?

Yes. A Discord or Telegram bot, a queue worker or a scheduled job runner all work the same way: the program runs, prints a start-detection line, and keeps running. It does not have to listen on the port if nothing needs to reach it, though a small health route is still useful for checking it from outside.

Do I have to deploy from GitHub?

No. You can upload source with the file manager or SFTP and restart, and the server builds whatever is in its files. GitHub deploys are the convenient path, and the only Git provider the panel connects to.

Can I use a nightly toolchain?

The server builds with the toolchain in its image, and we do not document a way to switch it to nightly. If you depend on a nightly feature, building elsewhere and uploading the binary is the practical route - check the startup command first to make sure it can run a prebuilt binary.

Was this helpful?