Bring Your Own Dockerfile: Deploy Anything That Runs in a Container

When your language or setup is not on the list, a Dockerfile is the contract. A Rust service, a two-stage build, a cache that makes the second deploy 40 seconds faster, and the four rules Light Cloud needs.

Bring Your Own Dockerfile: Deploy Anything That Runs in a Container
On this pageShow
  1. What you will build
  2. When to bring your own Dockerfile
  3. The four rules
  4. Before you start
  5. Step 1: Create the app
  6. Step 2: Write the service
  7. Step 3: Write the Dockerfile
  8. Step 4: Run it on your machine
  9. Step 5: Push and deploy
  10. Step 6: See the layer cache at work
  11. Troubleshooting

To deploy any app on Light Cloud with your own Dockerfile, put the Dockerfile at the root of the repository, EXPOSE the port your app listens on, read that port from the PORT environment variable, push to GitHub and click Deploy. Light Cloud builds your Dockerfile as it is, with a layer cache that covers every stage, and runs the image. Anything that runs in a Linux container can be deployed this way.

In my run a Rust service was live 3 minutes after the first click, and a new instance answered 0.13 seconds after it started.

What you will build

A small word-counting API written in Rust with axum, a language Light Cloud does not build on its own. The Dockerfile makes it deployable:

  • POST /count returns the characters, words and lines of a text.
  • GET /health answers {"status":"ok"}.
  • A two-stage Dockerfile compiles in a large Rust image and ships only the binary.

Source code: github.com/light-cloud-com/tutorial-dockerfile-app.

When to bring your own Dockerfile

Light Cloud builds Node.js, Python, Go, Java, .NET, PHP, Ruby and static sites without one. Bring your own when:

  • your language is not on that list (Rust, Elixir, Deno, C++, ...);
  • you need system packages, such as ffmpeg or a PDF renderer;
  • you already have a Dockerfile that works and want exactly that image in production.

The four rules

Whatever is inside, a Dockerfile that works on Light Cloud follows four rules:

  1. Listen on PORT, on 0.0.0.0. Light Cloud sets PORT; localhost inside a container is not reachable from outside.
  2. EXPOSE that port. Light Cloud reads it to know where to send traffic.
  3. Stop cleanly on SIGTERM. That is how an old instance is told to go.
  4. Log to standard output. One JSON object per line with a severity gets the right level in the Logs tab.

Before you start

  • Rust 1.85 or later, for the 2024 edition; I used 1.97.1. Only needed to run the app on your machine: the build happens in Docker.
  • Git and a GitHub account.
  • A free Light Cloud account at console.light-cloud.com with GitHub connected.
  • A terminal with curl. On Windows, use PowerShell 7 and curl.exe, which ships with Windows 10 and 11.

Step 1: Create the app

terminal
$ cargo new tutorial-dockerfile-app
    Creating binary (application) `tutorial-dockerfile-app` package
$ cd tutorial-dockerfile-app
$ cargo add axum tokio --features tokio/full
      Adding axum v0.8.9 to dependencies
      Adding tokio v1.53.1 to dependencies
$ cargo add serde --features derive
      Adding serde v1.0.229 to dependencies
$ cargo add serde_json
      Adding serde_json v1.0.151 to dependencies

Output is trimmed. cargo new also creates a Git repository with a .gitignore for /target.

Step 2: Write the service

src/main.rs
rust
use axum::{Json, Router, http::StatusCode, routing::{get, post}};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::signal::unix::{SignalKind, signal};

#[derive(Deserialize)]
struct CountRequest {
    text: String,
}

#[derive(Serialize)]
struct CountResponse {
    characters: usize,
    words: usize,
    lines: usize,
}

// One JSON object per line; the Logs tab reads "severity" and "message".
fn log(severity: &str, message: &str) {
    println!("{}", json!({ "severity": severity, "message": message }));
}

async fn root() -> Json<serde_json::Value> {
    Json(json!({ "service": "word counter", "version": 3, "try": "POST /count with {\"text\": \"...\"}" }))
}

async fn health() -> Json<serde_json::Value> {
    Json(json!({ "status": "ok" }))
}

async fn count(Json(body): Json<CountRequest>) -> Result<Json<CountResponse>, (StatusCode, String)> {
    if body.text.len() > 100_000 {
        return Err((StatusCode::PAYLOAD_TOO_LARGE, "text is longer than 100000 bytes".into()));
    }
    Ok(Json(CountResponse {
        characters: body.text.chars().count(),
        words: body.text.split_whitespace().count(),
        lines: body.text.lines().count(),
    }))
}

#[tokio::main]
async fn main() {
    // Light Cloud passes the port in PORT; 8080 matches EXPOSE in the Dockerfile.
    let port = std::env::var("PORT").unwrap_or_else(|_| "8080".into());
    let app = Router::new()
        .route("/", get(root))
        .route("/health", get(health))
        .route("/count", post(count));

    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap();
    log("INFO", &format!("listening on port {port}"));

    // Finish running requests when Light Cloud stops the instance with SIGTERM.
    axum::serve(listener, app)
        .with_graceful_shutdown(async {
            signal(SignalKind::terminate()).unwrap().recv().await;
            log("INFO", "SIGTERM received, shutting down");
        })
        .await
        .unwrap();
}

Rules 1, 3 and 4 are in the code: it binds 0.0.0.0 on PORT, it waits for SIGTERM to shut down, and it prints JSON log lines with a severity.

Step 3: Write the Dockerfile

Dockerfile
dockerfile
# Stage 1: compile. The Rust toolchain is large, but it never reaches production.
FROM rust:1.97-slim AS builder
WORKDIR /app

# Build the dependencies first, with an empty main.rs, so this slow layer is
# cached and only rebuilt when Cargo.toml or Cargo.lock change.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src

COPY src ./src
RUN touch src/main.rs && cargo build --release

# Stage 2: run. Only the binary goes into a small Debian image.
FROM debian:bookworm-slim
RUN useradd --system --uid 10001 app
COPY --from=builder /app/target/release/tutorial-dockerfile-app /usr/local/bin/app
USER app

# Light Cloud reads EXPOSE to know which port to send traffic to.
ENV PORT=8080
EXPOSE 8080
CMD ["app"]

Three habits in there are worth copying into any Dockerfile:

  • Two stages. The build stage has the compiler and every build tool; the final image has the binary and nothing else. The image you run is small and has less in it to attack.
  • Dependencies before source. Copying only Cargo.toml and Cargo.lock first, and building them on their own, puts the slow dependency build in a layer that survives code changes. The same pattern works as package.json then npm ci, or requirements.txt then pip install.
  • A non-root user. USER app means a bug in the app cannot write to the system. Port 8080 needs no special rights.

Keep the build context small:

.dockerignore
text
target
.git

Step 4: Run it on your machine

You do not need Docker for this: the same code runs with Cargo.

terminal
$ cargo run --release
    Finished `release` profile [optimized] target(s) in 1.80s
     Running `target/release/tutorial-dockerfile-app`
{"message":"listening on port 8080","severity":"INFO"}

Output is trimmed; the first run also compiles every dependency. The SIGTERM handler uses Unix signals, which is right for a container that runs on Linux, so on Windows run the app inside WSL (with Rust installed there) or only in Docker. Stopping it with Ctrl+C sends SIGINT, not SIGTERM, so you will not see the shutdown line locally. From a second terminal:

terminal
$ curl -X POST http://localhost:8080/count -H "content-type: application/json" -d '{"text":"Bring your own Dockerfile.\nDeploy anything."}'
{"characters":43,"words":6,"lines":2}

If you have Docker, docker build -t word-counter . and docker run -p 8080:8080 word-counter run exactly what Light Cloud will run.

Step 5: Push and deploy

terminal
$ git add -A
$ git commit -m "Rust word counter with a multi-stage Dockerfile"
# Create an empty repository named tutorial-dockerfile-app on github.com/new first.
$ git remote add origin https://github.com/YOUR-USERNAME/tutorial-dockerfile-app.git
$ git push -u origin main
To https://github.com/YOUR-USERNAME/tutorial-dockerfile-app.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

In the Light Cloud console, click New..., then Deploy from GitHub, and pick the repository. Light Cloud shows Dockerfile / Backend and Runs on a server: it found no framework it builds itself, and a Dockerfile that says how to build this one.

The detection banner Dockerfile Backend, based on Dockerfile, with Runs on a server highlighted

Port under Advanced is 8080, read from EXPOSE. Click Deploy. The first build compiles every dependency; in my run the service was live 3 minutes 17 seconds after the click.

The Production overview of tutorial-dockerfile-app with the Deployed badge and the URL card highlighted

terminal
$ curl -X POST https://main-tutorial-dockerfile-app-yourworkspace.light-cloud.io/count -H "content-type: application/json" -d '{"text":"Bring your own Dockerfile.\nDeploy anything."}'
{"characters":43,"words":6,"lines":2}

Step 6: See the layer cache at work

Change something in src/main.rs, commit and push. Deploy on push rebuilds the image, and this time the dependency layers come from the cache. From the build logs of two pushes in a row:

BuildDependency stageApp compileWhole build
First, empty cache51.2 s7.5 s1 min 55 s
After a code changecached8.0 s1 min 12 s

The cache covers every stage of the Dockerfile, including the builder stage that never ships. That is why the order of COPY lines matters: everything above the first changed file is reused.

In the Logs tab the rollout is visible too: the new instance logs listening on port 8080 0.13 seconds after it starts, and the old one logs SIGTERM received, shutting down before it goes.

Troubleshooting

The deploy fails the health check: the container did not listen on the port

The app listens on 127.0.0.1 or on a fixed port. Bind 0.0.0.0 and read PORT. Check that EXPOSE matches what the app uses when PORT is not set.

More tutorials