← Back to Blog

Rust CI/CD with GitLab and Render: From Git Push to Live API

Build a practical CI/CD pipeline for a Rust web application using GitLab CI/CD and Render, with automated testing, release builds, secure deployment variables, and deployment verification.

1. Introduction: Why CI/CD for Rust?

Rust catches many mistakes at compile time, but a compiler cannot tell you whether the code you pushed today still builds in a clean environment, passes its tests, and actually reaches a running server. Someone has to check that, and doing it by hand every time is slow and easy to forget.

CI/CD stands for Continuous Integration and Continuous Delivery/Deployment. In practice it means a server runs your checks and your deployment steps automatically whenever you push code.

Developer Test manually Build manually Deploy manually
Git Push CI/CD Test Build Deploy Live API

This article walks through a small Proof of Concept (POC): a Rust web app built with Actix Web, tested and built by GitLab CI/CD, and deployed to Render when GitLab calls a Render Deploy Hook.

Scope of This Article

Everything below comes from one small POC. It does not report test counts, build times, or production results, and it does not claim more than the pipeline actually does. Where a detail was not part of the POC, you will see a placeholder or an explicit "not specified" note.

2. What We Are Building

The POC has one goal: pushing code to the main branch should test it, build it, and update a live Rust API, with no manual steps in between.

Developer GitLab Repository GitLab CI/CD Test Build Deploy Render Deploy Hook Render Live Rust API

3. Technologies Used

TechnologyPurpose
RustBackend language
Actix WebWeb framework
CargoBuild and test
GitVersion control
GitLabRepository and CI/CD
GitLab CI/CDAutomation
RenderCloud hosting
Render Deploy HookDeployment trigger
curlCalls the Deploy Hook

The pipeline uses two Docker images, rust:latest and curlimages/curl:latest. They are only the environments in which GitLab runs the jobs. Docker is not how the application itself is built or deployed here.

4. CI/CD Architecture

Rust CI/CD architecture A developer pushes to main on GitLab. The GitLab CI/CD pipeline runs three stages in order: test, build, deploy. The deploy job sends a curl POST to the Render Deploy Hook. Render then deploys the service, and the live Rust API is available. GitLab CI/CD pipeline (.gitlab-ci.yml) DeveloperGitLab testbuilddeploy Deploy HookRenderLive Rust API git push mainrepository cargo build, cargo testcargo build --releasecurl POST private URLbuilds and starts appHTTP endpoints
Stages run in the order defined in .gitlab-ci.yml. Each stage starts only if the previous one passed.
  1. Push: the developer pushes to main and GitLab creates a pipeline.
  2. Test: a clean container compiles the code and runs Cargo tests.
  3. Build: a release build confirms the optimized binary compiles.
  4. Deploy: a small job sends a POST request to Render's Deploy Hook.
  5. Render: receives the request, deploys the service, and the API is live.

5. Create the Rust + Actix Web Application

The POC uses a Rust project named rust-ci-demo that exposes HTTP endpoints with Actix Web. If you are starting from scratch, the usual commands are the following (they are standard Cargo usage, not shown in the source document):

cargo new rust-ci-demo
cd rust-ci-demo
cargo add actix-web

The entry point is src/main.rs. This is the main function from the POC (imports and the handler functions are not shown in the source, so they are omitted here):

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let port = env::var("PORT")
        .unwrap_or_else(|_| "8080".to_string());

    println!("Starting server on port {}", port);

    test_add();
    test_subtract();
    test_multiply();

    HttpServer::new(|| {
        App::new()
            .service(hello_world)
            .service(hello_name)
    })
    .bind(format!("0.0.0.0:{}", port))?
    .run()
    .await
}

Two handlers, hello_world and hello_name, are registered as services. The main function also calls three small helpers (test_add, test_subtract, test_multiply) at startup. Their bodies are not part of the source, so this article does not describe them.

To run the app locally:

cargo run

cargo run compiles the project (in debug mode by default) and starts the resulting program.

6. Configure Rust for Cloud Deployment

Two lines in main matter most for hosting. Without them, an app that works on your laptop can fail to start or be unreachable on a cloud platform.

0.0.0.0

.bind(format!("0.0.0.0:{}", port))?

Binding to 127.0.0.1 (localhost) accepts connections only from the same machine. In a hosted environment, requests arrive from outside the container, so the server must listen on all network interfaces. 0.0.0.0 means exactly that.

PORT

let port = env::var("PORT")
    .unwrap_or_else(|_| "8080".to_string());

The hosting platform decides which port the app should use and passes it in the PORT environment variable. The code reads it, and falls back to 8080 when it is not set. In the POC, 8080 is only the local fallback used during local testing.

7. Test the Rust Application Locally

Start the app with cargo run. The console should print the line from the code, using the fallback port when PORT is not set:

Starting server on port 8080

Then call an endpoint from a second terminal. The exact URL paths are defined by the route attributes on hello_world and hello_name, which the source document does not show, so use the paths from your own handlers:

curl http://localhost:8080/<endpoint-path>

8. Rust Testing with Cargo

CommandPurpose
cargo buildCompile the project
cargo testRun Rust tests
cargo build --releaseCreate the release build

cargo build proves the code compiles. cargo test runs the project's tests and fails if any of them fail. cargo build --release compiles with optimizations, which is slower to build but closer to what runs in a hosted environment.

Testing comes before deployment for a simple reason: a failed test stops the pipeline, so broken code never reaches the deploy job. The source document does not state how many tests the project has, so no count is given here.

9. Create and Connect the GitLab Repository

Create an empty project on GitLab, then connect your local repository to it. Replace the placeholders with your own values:

git branch -M main
git remote add origin git@gitlab.com:<your-namespace>/<your-repository>.git
git push -u origin main

git branch -M main renames the current branch to main. The POC uses main everywhere: GitLab pushes go to it, and the Render service is connected to it, so all three parts of the setup agree on which branch is "the deployed one".

One practical detail from the POC: the remote repository already contained an initial README commit, so the local and remote histories had to be merged before the final push succeeded.

10. Understanding .gitlab-ci.yml

.gitlab-ci.yml is a file in the root of your repository that describes the pipeline. GitLab reads it on every push. The POC started with Test and Build and later added Deploy.

stages:
  - test
  - build
  - deploy

In this POC each stage has exactly one job.

11. Test Stage

test:
  image: rust:latest
  stage: test
  script:
    - cargo build --verbose
    - cargo test --verbose

12. Build Stage

build:
  image: rust:latest
  stage: build
  script:
    - cargo build --release

A normal cargo build produces a debug build: fast to compile, with extra checks and no heavy optimization. cargo build --release optimizes the code, which takes longer but matches how you would run the app for real.

What This Build Job Does Not Do

The POC pipeline does not save the release binary or hand it to Render. The build job confirms that the release build compiles. Render runs its own build command (cargo build --release) when it deploys.

13. Deploy Stage

deploy:
  image: curlimages/curl:latest
  stage: deploy
  script:
    - curl --fail --request POST --url "$RENDER_DEPLOY_HOOK_URL"

A green Deploy job means the hook request was accepted. It does not by itself prove the app started, which is why the verification step below checks Render.

14. Deploy the Rust Application to Render

The POC created a Render Web Service with these settings:

SettingValue in the POC
SourceThe GitLab repository
Branchmain
LanguageRust
Root directoryProject root
Build commandcargo build --release
Start commandcargo run --release
InstanceFree
Auto-DeployOff (see section 17)

Render, not GitLab, compiles and starts the app. GitLab's job is to validate the code and then tell Render to deploy.

Optional, not part of this POC: a paid instance type (Render's dashboard warns that a free instance spins down after inactivity and can delay requests by 50 seconds or more), health checks, and a separate staging service.

15. What Is a Render Deploy Hook?

A Deploy Hook is a private URL. Sending a request to it triggers a deployment of that Render service. Render's documentation describes it as a secret and accepts a basic GET or POST request with no special headers.

GitLab Deploy Job curl POST Render Deploy Hook Render Deployment

This keeps the two systems loosely coupled. GitLab needs no Render account credentials or API client; it only needs one URL. The trade-off is that the URL itself grants the power to trigger deployments, which is why the next section matters.

16. GitLab CI/CD Variables and Secrets

The hook URL is stored in a GitLab CI/CD variable named RENDER_DEPLOY_HOOK_URL (Settings → CI/CD → Variables). The job reads it as $RENDER_DEPLOY_HOOK_URL. The real URL never appears in .gitlab-ci.yml, the repository, or this article.

SettingValue in the POC
TypeVariable
EnvironmentAll
VisibilityMasked
Protect variableOff for this POC
Expand variable referenceOff

Masked hides the value in job logs. Expand variable reference being off means a $ in the value is not treated as a reference to another variable. Protect variable restricts a variable to pipelines on protected branches and tags; it was off in this POC, so any pipeline in the project can read it.

Treat the Deploy Hook URL Like a Password

  • Never commit it, paste it in chat, or include it in documentation or screenshots.
  • Masking hides the value in logs, but it is not a complete defense. GitLab's documentation warns that changes to .gitlab-ci.yml can expose variables, so review merge requests that touch it.
  • For anything beyond a POC, consider turning Protect variable on, with main as a protected branch.

17. Render Auto-Deploy vs GitLab Deploy Hook

This was the most useful real-world finding in the POC. By default, Render redeploys automatically when the linked branch receives a push. Once GitLab also triggers a deploy through the hook, two things want to deploy the same commit.

Avoid Duplicate Deployments

When both Render Auto-Deploy and the GitLab Deploy Hook were enabled, the same commit was deployed twice: one deployment from Render Auto-Deploy and one from the GitLab Deploy Hook. If GitLab controls deployment through the Deploy Hook, turn Render Auto-Deploy off.

In the POC, Auto-Deploy was turned off in the Render service settings. From then on, GitLab was the only thing triggering deployments. It also means a failed test can no longer be bypassed by Render deploying on its own.

18. Complete .gitlab-ci.yml

stages:
  - test
  - build
  - deploy

# Job to test the project
test:
  image: rust:latest
  stage: test
  script:
    - cargo build --verbose
    - cargo test --verbose

# Job to build the project
build:
  image: rust:latest
  stage: build
  script:
    - cargo build --release

# Job to deploy the project to Render
deploy:
  image: curlimages/curl:latest
  stage: deploy
  script:
    - curl --fail --request POST --url "$RENDER_DEPLOY_HOOK_URL"

Reading it top to bottom: the stages list sets the order; test compiles and runs the tests; build proves the release build works; deploy calls Render. Because each job runs in its own fresh container, the build job compiles again from scratch. This POC does not share files between jobs.

19. Push Code and Trigger the Pipeline

git add .gitlab-ci.yml
git commit -m "add render deployment stage"
git push origin main

GitLab sees the new commit on main and creates a pipeline automatically. No button is pressed.

Push
 ↓
Pipeline
 ↓
Test
 ↓
Build
 ↓
Deploy

20. Verify the Deployment

GitLab

Open the pipeline in GitLab. In the POC, the final pipeline looked like this:

Test    ✓ Passed
Build   ✓ Passed
Deploy  ✓ Passed

Render

Open the service's deployment logs in the Render dashboard and confirm the Rust service started. This is the step that proves the app is running, not just that the hook was called.

Live API

Call an endpoint on the live URL (replace the placeholders with your own):

curl https://<your-render-service-url>/<endpoint-path>

On a free Render instance, the first request after a period of inactivity can be slow, so allow extra time before assuming something is broken.

21. Common Problems and Solutions

SymptomCheck
Pipeline does not deploy, or the Deploy job failsConfirm the GitLab variable is named exactly RENDER_DEPLOY_HOOK_URL and holds the complete hook URL.
Application does not start on RenderCheck that the server binds to 0.0.0.0 and reads PORT. Read the Render deployment logs.
Each push deploys twiceRender Auto-Deploy is still on. Turn it off so only the GitLab hook deploys.
Deploy Hook URL was exposedUse Regenerate Hook in Render, then update the GitLab variable with the new URL.
First push to GitLab is rejectedThe remote may already have a README commit. Merge the local and remote histories, then push.

22. What Happens After git push?

git push
 ↓
GitLab detects the commit
 ↓
Pipeline starts
 ↓
┌─ GitLab CI/CD ─────────────────────────────┐
│  Rust environment starts (rust:latest)     │
│   ↓                                        │
│  cargo build --verbose                     │
│   ↓                                        │
│  cargo test --verbose                      │
│   ↓                                        │
│  Release build (cargo build --release)     │
│   ↓                                        │
│  Deploy job (curlimages/curl)              │
│   ↓                                        │
│  curl POST to $RENDER_DEPLOY_HOOK_URL      │
└────────────────────────────────────────────┘
 ↓
Render Deploy Hook
 ↓
┌─ Render ───────────────────────────────────┐
│  Render deployment                         │
│   ↓                                        │
│  Rust application starts                   │
└────────────────────────────────────────────┘
 ↓
Live API

Notice the hand-off in the middle. Everything in the first box is GitLab validating your code. Everything in the second box is Render building and running it.

23. What This POC Demonstrates vs Production CI/CD

Current POC

Test
 ↓
Build
 ↓
Deploy
 ↓
Render

Possible Production Enhancements (Future, Not Implemented)

Two observations about the POC configuration itself, worth knowing before you copy it: the pipeline file has no rules limiting the deploy job to main, and it uses the moving rust:latest image tag rather than a pinned Rust version. Both are reasonable things to tighten later.

24. What We Learned

25. Conclusion

A developer pushes Rust code. GitLab validates it with Cargo, creates a release build, and calls Render's Deploy Hook. Render deploys the application, and the live Rust API becomes available.

The POC is deliberately small, but the pattern is the useful part: one push, an ordered set of checks, one private URL, and a running service.

Official References

← Back to All Articles