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.
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.
- GitLab repository: stores the code and starts a pipeline on each push.
- GitLab CI/CD: runs the test, build, and deploy jobs defined in
.gitlab-ci.yml. - Render Deploy Hook: a private URL that tells Render to deploy.
- Render: hosts the running Rust web service.
3. Technologies Used
| Technology | Purpose |
|---|---|
| Rust | Backend language |
| Actix Web | Web framework |
| Cargo | Build and test |
| Git | Version control |
| GitLab | Repository and CI/CD |
| GitLab CI/CD | Automation |
| Render | Cloud hosting |
| Render Deploy Hook | Deployment trigger |
| curl | Calls 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
.gitlab-ci.yml. Each stage starts only if the previous one passed.- Push: the developer pushes to
mainand GitLab creates a pipeline. - Test: a clean container compiles the code and runs Cargo tests.
- Build: a release build confirms the optimized binary compiles.
- Deploy: a small job sends a POST request to Render's Deploy Hook.
- 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
| Command | Purpose |
|---|---|
cargo build | Compile the project |
cargo test | Run Rust tests |
cargo build --release | Create 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
- Stages define the order. Stages run one after another, and a later stage starts only if the earlier one passed.
- Jobs are the units of work. Each job names its stage, and the jobs in a stage run together.
- Image is the Docker image in which the job runs, so every run starts from a clean, known environment.
- Script is the list of shell commands the job executes. If any command fails, the job fails.
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
test:is the job name.image: rust:latestgives the job a container with the Rust toolchain and Cargo.stage: testplaces the job in the first stage.cargo build --verbosecompiles the project and prints detailed output, which helps when reading logs.cargo test --verboseruns the tests. A failure stops the pipeline here.
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"
- Why curl: the deploy step is just one HTTP request, so a small image with curl is enough. No Rust toolchain is needed.
- Why the Deploy Hook: it is how Render lets an outside system start a deployment.
- Why a variable: the hook URL is a secret, so it lives in a GitLab CI/CD variable instead of the repository.
--fail: makes curl exit with an error on HTTP error responses, so a rejected request fails the job instead of passing silently.
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:
| Setting | Value in the POC |
|---|---|
| Source | The GitLab repository |
| Branch | main |
| Language | Rust |
| Root directory | Project root |
| Build command | cargo build --release |
| Start command | cargo run --release |
| Instance | Free |
| Auto-Deploy | Off (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.
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.
| Setting | Value in the POC |
|---|---|
| Type | Variable |
| Environment | All |
| Visibility | Masked |
| Protect variable | Off for this POC |
| Expand variable reference | Off |
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.ymlcan expose variables, so review merge requests that touch it. - For anything beyond a POC, consider turning Protect variable on, with
mainas 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
| Symptom | Check |
|---|---|
| Pipeline does not deploy, or the Deploy job fails | Confirm the GitLab variable is named exactly RENDER_DEPLOY_HOOK_URL and holds the complete hook URL. |
| Application does not start on Render | Check that the server binds to 0.0.0.0 and reads PORT. Read the Render deployment logs. |
| Each push deploys twice | Render Auto-Deploy is still on. Turn it off so only the GitLab hook deploys. |
| Deploy Hook URL was exposed | Use Regenerate Hook in Render, then update the GitLab variable with the new URL. |
| First push to GitLab is rejected | The 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)
- Linting
- More comprehensive tests
- Security scanning
- A staging environment
- Approval before production
- Artifact management
- Environment-specific deployment
- A rollback strategy
- Monitoring
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
- GitLab CI/CD can automate Rust testing, building, and deployment.
- Cargo handles the Rust build and test commands.
- CI/CD variables keep deployment secrets out of source code.
- Render Deploy Hooks connect a GitLab deploy job to a Render deployment.
- Render Auto-Deploy should be off when GitLab controls deployment through the Deploy Hook.
- A cloud-hosted Rust app needs to listen on
0.0.0.0and use the platform-providedPORT.
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.