Why host your CI/CD with Jenkins
A hosted CI is convenient until builds multiply: every minute beyond the quota is billed, large projects wait their turn, and the deployment keys of your servers are stored at the provider. Jenkins reverses this model. It runs on your VPS, executes as many builds as your machines allow and keeps your credentials encrypted at home.
It is also the tool most teams already know: a Jenkinsfile reads without training, documentation is plentiful, and almost every tool on the market has its Jenkins plugin.
What Jenkins brings
- Pipelines as code — a Jenkinsfile versioned with the project describes every stage, reviewed in code review like the rest.
- Git webhooks — GitHub, GitLab, Gitea or Forgejo trigger the build on every push or pull request.
- Multibranch — every branch and every pull request gets its own pipeline, created automatically.
- Build agents — builds run on dedicated machines, connected over SSH.
- Over 2,000 plugins — Docker, Kubernetes, SonarQube, Slack, JUnit test reports.
- Encrypted credentials — SSH keys and tokens injected into the pipeline, masked in the logs.
Jenkins versus other self-hosted CI tools
Scroll the table
| Jenkins | Woodpecker CI | OneDev | |
|---|---|---|---|
| Licence | MIT | Apache-2.0 | MIT |
| Pipeline definition | Jenkinsfile (Groovy) | YAML | YAML or visual editor |
| Built-in Git forge | No, plugs into yours | No, plugs into yours | Yes |
| Plugin ecosystem | Over 2,000 plugins | Plugins as Docker images | Built-in features |
| Best for | Complex pipelines, existing teams | Simple container pipelines | Small team wanting all-in-one |
Prerequisites
An Ubuntu 24.04 VPS with 2 GB of RAM and 2 vCPUs is enough for the controller; plan 4 GB if your builds run on the same machine. You need Docker and the Compose plugin, a domain name (or a subdomain) pointing to the VPS, and nginx with a TLS certificate to publish it over HTTPS. Jenkins must answer on a stable HTTPS address: it is the one your repositories call for webhooks.
Install Jenkins with Docker Compose
Create the Compose file
This
docker-compose.ymlstarts Jenkins LTS on Java 21. The port is only published on the loopback interface: only nginx reaches it. All data (jobs, plugins, credentials, history) lives in thejenkins-homevolume. Port 50000 for inbound agents is not published: agents connect over SSH.services: jenkins: image: jenkins/jenkins:2.580.1-lts-jdk21 restart: unless-stopped ports: - "127.0.0.1:8104:8080" environment: JAVA_OPTS: -Djava.awt.headless=true volumes: - jenkins-home:/var/jenkins_home healthcheck: test: ["CMD-SHELL", "curl -fsS -o /dev/null http://localhost:8080/login || exit 1"] interval: 15s retries: 10 start_period: 60s volumes: jenkins-home: {}Start the container
Put the file in
/opt/jenkins, then start the stack. The first start takes about thirty seconds;docker compose psmust show thehealthystate.mkdir -p /opt/jenkins && cd /opt/jenkins docker compose up -d docker compose psPublish Jenkins over HTTPS behind nginx
In the
serverblock of your domain, forward theX-Forwarded-*headers: without them, Jenkins generates HTTP links and reports a broken reverse proxy.location / { proxy_pass http://127.0.0.1:8104; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Port 443; }Unlock Jenkins
Open
https://your-domain. Jenkins asks for the initial password written to its data volume:docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPasswordThen install the suggested plugins, create your administrator account and confirm the instance's HTTPS URL.
Do not run builds on the controller
The Jenkins controller holds all your credentials. A build running on it can read them. In Manage Jenkins, set the number of executors of the built-in node to 0 and connect at least one agent over SSH. For the same reason, do not mount the VPS's Docker socket into the Jenkins container: it would give any build root access to the server.
Write a first pipeline
Create a "Multibranch Pipeline" pointing to your repository, then add a Jenkinsfile at the root. This example builds and tests every branch, and only deploys the main branch, on an agent labelled linux:
pipeline {
agent { label 'linux' }
stages {
stage('Build') { steps { sh 'make build' } }
stage('Test') { steps { sh 'make test' } }
stage('Deploy') {
when { branch 'main' }
steps { sh './deploy.sh' }
}
}
}Finally, add a webhook in your repository to https://your-domain/github-webhook/ for GitHub, or to the URL given by your forge's plugin. Every push then triggers a build.
Credentials and build agents
SSH keys, API tokens and deployment passwords go into Manage Jenkins, Credentials. Jenkins stores them encrypted in its volume and only injects them into a build when a step asks for them, with withCredentials or the environment directive of a Jenkinsfile; their value is masked in the logs. Give each credential the narrowest possible scope: one deployment key per target server, a read-only token to clone repositories.
For agents, the simplest option is a second VPS with Java and Docker installed. In Manage Jenkins, Nodes, add a permanent node with the "via SSH" launch method, the VPS address and an SSH key saved as a credential. Give it a label, for example linux, and target it in your pipelines. The controller then only orchestrates, and a compromised build never reaches your other secrets.
Back up and update Jenkins
The whole state of Jenkins lives in the jenkins-home volume: jobs, configuration, plugins, encrypted credentials and build history. An archive of this volume is enough to restore everything. Stop Jenkins during the copy to get a consistent archive:
cd /opt/jenkins
docker compose stop jenkins
docker run --rm -v jenkins_jenkins-home:/data -v \"$PWD\":/backup alpine tar czf /backup/jenkins-home.tar.gz -C /data .
docker compose start jenkinsTo update, follow the LTS line, released roughly every twelve weeks with intermediate fixes: replace the image tag in docker-compose.yml, then run docker compose pull and docker compose up -d. Then update the plugins from Manage Jenkins, Plugins, and read the security advisories published by the project: most fixed vulnerabilities concern plugins, not the core.
Troubleshooting
"It appears that your reverse proxy set up is broken": nginx does not forward X-Forwarded-Proto or the Jenkins URL is not HTTPS. Fix the location block, then the URL in Manage Jenkins, System.
The container keeps restarting: read the logs. The most frequent cause is a lack of memory; give the VPS at least 2 GB of RAM.
docker compose logs --tail=100 jenkinsWebhooks do not arrive: check the webhook's delivery history in the repository, then that the address called is the public HTTPS URL of Jenkins.
One-click install from the ServOrbit Marketplace
On a ServOrbit VPS, Jenkins installs from the Marketplace: choose the address, click Install, and the script deploys Jenkins LTS in Docker, with its data volume and the nginx reverse proxy over HTTPS. The initial administrator password is shown in your client area. No domain? The VPS's free subdomain is enough, HTTPS included.