Continuous integration and delivery pipelines are the heartbeat of modern software teams. When every merge, test, and deployment depends on a hosted CI service, you trade control for convenience: build queues, per-minute billing, and opaque agent images that may not match your production environment. Jenkins is the open-source automation server that has powered CI/CD for more than a decade. It supports thousands of plugins, integrates with every major version control system, and runs on your own infrastructure so you control the agent images, network routes, and artifact retention policies.
This guide deploys Jenkins on Ubuntu with Docker Compose, Caddy for automatic HTTPS, and the host Docker socket for dynamic agent containers. By the end, you will have a production-ready CI server accessible at a public domain, with persistent build history, plugin state, and a backup strategy that covers both the controller home directory and your job definitions.
Running Jenkins on your own hardware means you avoid per-seat pricing, can pin agent images to the exact OS and toolchain versions used in production, and keep build artifacts and logs inside your network perimeter. You also gain the ability to schedule maintenance windows, enforce custom security policies, and integrate directly with internal artifact stores without routing data through third-party clouds.
Architecture and flow overview
Caddy sits at the edge and terminates TLS with automatically managed Let's Encrypt certificates. It reverse-proxies HTTPS traffic to the Jenkins controller container on its internal HTTP port. The controller container serves the web UI, orchestrates job execution, and stores build history, plugin configurations, and credentials in a persistent Docker volume. The controller also mounts the host Docker socket so it can spawn ephemeral agent containers directly on the host daemon, keeping agent startup fast and image caching efficient.
All Jenkins state lives in /var/jenkins_home inside the container, which is backed by a named Docker volume on the host. Caddy binds to public ports 80 and 443, while Jenkins binds only inside the Compose network. UFW drops everything except SSH and web traffic. A nightly cron job archives the Jenkins home volume to a remote target so you can rebuild the controller in minutes if the host fails.
- Edge: Caddy handles TLS termination, security headers, and HTTP-to-HTTPS redirects.
- Controller: Jenkins LTS container on the internal bridge network.
- Agents: Ephemeral containers spawned by the Docker plugin using the host daemon.
- State: Named volume for
JENKINS_HOMEand bind mounts for the Docker socket. - Ops: systemd handles host reboots; a cron job handles nightly backups.
Prerequisites
- Ubuntu 22.04 or 24.04 LTS server with at least 2 vCPU, 4 GB RAM, and 40 GB SSD.
- A DNS A record pointing your domain to the server IP address.
- Ports 80 and 443 open on the host firewall and cloud security group.
- Docker Engine 24.x and Docker Compose plugin installed.
- A non-root user with sudo privileges and a working SSH key pair.
- At least 10 GB of free space for build artifacts, plugin caches, and automated backups.
Step-by-step deployment
1) Create directories and permissions
Create the project directory structure and set ownership so the Jenkins user inside the container can write to the volume.
sudo mkdir -p /opt/jenkins/{compose,backups}
sudo mkdir -p /var/lib/jenkins_home
sudo groupadd -f docker
sudo usermod -aG docker $USER
sudo chown -R 1000:1000 /var/lib/jenkins_home
sudo chown -R $USER:$USER /opt/jenkins2) Store runtime options in an environment file
Keep memory tuning and domain settings out of the Compose file by placing them in a restricted .env file.
cat > /opt/jenkins/compose/.env << 'EOF'
JENKINS_DOMAIN=jenkins.example.com
JAVA_OPTS=-Xmx2g -Xms512m
EOF
chmod 600 /opt/jenkins/compose/.env3) Configure the Caddy reverse proxy
Caddy will automatically obtain and renew a Let's Encrypt certificate for your domain.
cat > /opt/jenkins/compose/Caddyfile << 'EOF'
jenkins.example.com {
reverse_proxy jenkins:8080
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
EOF4) Define the Docker Compose services
The Compose file defines the Jenkins controller, mounts the persistent home directory, and gives the container access to the host Docker socket for dynamic agents.
cat > /opt/jenkins/compose/docker-compose.yml << 'EOF'
services:
jenkins:
image: jenkins/jenkins:lts-jdk17
container_name: jenkins
restart: unless-stopped
user: jenkins
env_file: .env
environment:
- JENKINS_OPTS=--sessionTimeout=480
volumes:
- jenkins_home:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- jenkins_net
caddy:
image: caddy:2-alpine
container_name: jenkins_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- jenkins_net
depends_on:
- jenkins
volumes:
jenkins_home:
driver: local
driver_opts:
type: none
o: bind
device: /var/lib/jenkins_home
caddy_data:
caddy_config:
networks:
jenkins_net:
driver: bridge
EOF5) Start the stack and verify health
Bring up the services and confirm the controller starts before Caddy begins proxying traffic.
cd /opt/jenkins/compose
docker compose up -d
docker compose logs -f jenkinsWait for the log line Jenkins is fully up and running, then press Ctrl+C and verify Caddy has obtained a certificate.
docker compose ps
curl -sI https://jenkins.example.com/login | head -n 16) Complete first-time setup wizard
When Jenkins boots for the first time, it prints an initial admin password to the logs. Retrieve it, unlock the UI, and install the recommended plugins.
docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPasswordOpen https://jenkins.example.com in a browser, paste the password, choose Install suggested plugins, and create the first admin user. After setup, navigate to Manage Jenkins → Configure Global Security and enforce CSRF protection and matrix-based security if you have multiple operators.
7) Configure Docker cloud agents
Install the Docker plugin from the plugin manager, then add a Docker cloud under Manage Jenkins → Clouds → New cloud. Use the host socket URI unix:///var/run/docker.sock and define a template using the jenkins/inbound-agent image. Set labels like docker so pipelines can target them with agent { label 'docker' }.
8) Set up automated backups
Jenkins home contains everything except the host Docker images. A nightly tarball plus off-site sync is the simplest reliable backup.
cat > /opt/jenkins/backups/backup-jenkins.sh << 'EOF'
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/opt/jenkins/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
ARCHIVE="$BACKUP_DIR/jenkins_home_$TIMESTAMP.tar.gz"
# Stop the controller briefly for a consistent snapshot
cd /opt/jenkins/compose
docker compose stop jenkins
tar czf "$ARCHIVE" -C /var/lib jenkins_home
# Restart immediately
docker compose start jenkins
# Rotate local backups (keep 7 days)
find "$BACKUP_DIR" -name 'jenkins_home_*.tar.gz' -mtime +7 -delete
# Optional: sync to S3-compatible remote
# rclone sync "$ARCHIVE" remote:jenkins-backups
EOF
chmod +x /opt/jenkins/backups/backup-jenkins.sh
# Schedule nightly at 02:00
(sudo crontab -l 2>/dev/null; echo "0 2 * * * /opt/jenkins/backups/backup-jenkins.sh") | sudo crontab -Configuration and secrets handling
Treat the .env file as a secret. It contains memory settings and the domain name. Set permissions to 600, exclude the directory from version control, and rotate the admin password through the Jenkins UI after first login. Never store production credentials inside job definitions; use the built-in Credentials plugin with scoped domains and folder-level stores.
For TLS, Caddy manages certificates automatically and stores them in the caddy_data volume. Because Caddy runs as a non-root user inside its container, the data volume handles permissions without host intervention. If you need to pin a custom certificate, mount the PEM files read-only into /data/caddy/certificates and reference them in the Caddyfile.
Agent images should be treated as immutable infrastructure. Build a custom agent image that contains your exact toolchain versions, push it to a private registry, and reference that image in the Docker cloud template. This prevents version drift between CI builds and production deployments.
Verification
- Open
https://jenkins.example.com/loginand confirm the certificate is valid and the padlock shows no warnings. - Sign in with the admin user created during setup and verify the dashboard loads without mixed-content errors.
- Create a test freestyle job named
health-check, add a shell step that runsecho "CI is healthy", and build it. The build should succeed and show the output in the console. - Run the backup script manually and confirm the resulting tarball contains
jenkins_home/config.xmland job directories. - Reboot the host and verify that both Jenkins and Caddy start automatically via the restart policies.
- Check
docker compose psand confirm both services showUpstatus and Caddy is listening on 80 and 443.
Common issues and fixes
Jenkins fails to start with permission denied
If the controller exits immediately, check that /var/lib/jenkins_home is owned by UID 1000. The official image runs as the jenkins user (UID 1000). Fix with sudo chown -R 1000:1000 /var/lib/jenkins_home and recreate the container.
Docker agents cannot connect to the host daemon
Verify the host user running Docker Compose is in the docker group. The socket mount relies on host-level permissions. If the Jenkins container cannot read /var/run/docker.sock, add the container user to the host docker group by setting group_add: ["999"] in the Compose file, replacing 999 with your host docker GID.
Plugins fail to download behind a corporate proxy
If the server sits behind an HTTP proxy, set -Dhttp.proxyHost and -Dhttps.proxyHost inside JAVA_OPTS in the .env file. Restart the controller and verify plugin downloads resume.
Out of memory during large builds
Java heap defaults may be too low for heavy pipelines. Increase the memory available to the controller by adjusting -Xmx in JAVA_OPTS. If builds themselves run out of memory, increase the host RAM or switch agent containers to a dedicated worker host.
Caddy shows 502 Bad Gateway after restart
This usually means Jenkins is still starting when Caddy tries to proxy. Caddy retries automatically, but if the controller takes more than a minute, ensure the restart policy is set and verify the logs for startup errors. Manually restart Caddy after Jenkins is healthy if necessary.
Backups are large and slow
Exclude the workspace and builds directories from the tarball if you only need configuration and job definitions. Add --exclude='jenkins_home/workspace' --exclude='jenkins_home/builds' to the tar command. Retain build logs separately with a shorter rotation if required.
FAQ
Can I use the host Docker daemon instead of a separate DinD service?
Yes, and that is the pattern used in this guide. Mounting /var/run/docker.sock is the simplest production approach for a single-host deployment. It avoids TLS certificate management between a DinD container and the controller. For multi-host setups, consider a dedicated Docker swarm or Kubernetes agent pool.
How do I add static SSH agents on remote hosts?
Install the SSH Build Agents plugin, then navigate to Manage Jenkins → Nodes and add a permanent node. Provide the host IP, credentials from the Credentials store, and a remote root directory such as /opt/jenkins-agent. The master will connect over SSH and launch the agent jar automatically.
What is the safest upgrade process for Jenkins?
Always back up JENKINS_HOME before upgrading. Pin the image tag to a specific LTS version in the Compose file rather than lts. Test plugin compatibility in a staging container first. To upgrade, stop the stack, update the tag, pull the image, and start again. Roll back by reverting the tag and restoring the backup if the UI shows plugin errors.
Can I enforce LDAP or SSO authentication?
Yes. Install the LDAP or SAML plugin, then configure the security realm under Manage Jenkins → Security. For OAuth providers such as GitHub or Google, use the GitHub Authentication or Google Login plugins. Map external groups to Jenkins roles with the Role-based Authorization Strategy plugin.
How do I restrict which users can create jobs?
Enable matrix-based security and uncheck the overall Job → Create permission for anonymous and authenticated users. Grant Job → Create only to an admin group. You can also organize jobs into folders and apply folder-level permissions so teams manage their own pipelines without seeing others.
What is the recommended retention policy for builds?
Set the Discard old builds option in each job configuration. A practical default is to keep the last 30 builds or builds from the last 30 days, whichever is larger. For artifacts, set a shorter retention such as 10 days or the last 5 successful builds to prevent disk exhaustion. Monitor /var/lib/jenkins_home usage with a node exporter or simple cron alert.
Internal links
- Production Guide: Deploy Drone CI with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy SonarQube with Docker Compose + Caddy + PostgreSQL on Ubuntu
- Production Guide: Deploy Rundeck with Docker Compose + Caddy + PostgreSQL on Ubuntu
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.