Rundeck is an open-source runbook automation server: it stores operational procedures as jobs, runs them over SSH on groups of servers, keeps a full log of who ran what, and lets you hand specific jobs to other people without giving them shell access. In this tutorial you will install Rundeck on Ubuntu 24.04 from the official repository, publish it over HTTPS with Nginx, register remote nodes, load two incident response jobs from YAML, restrict them to an on-call group with an ACL policy and trigger one from a webhook.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS with at least 2 CPUs and 4 GB of RAM for a small team, for example a CubePath VPS. Upstream recommends 8 GB for production use.
  • A non-root user with sudo privileges and UFW enabled with OpenSSH allowed.
  • A domain name with an A record for a subdomain such as rundeck.your_domain pointing to the server.
  • One or more Linux servers to manage (the "nodes"), reachable from the Rundeck server over SSH. This guide calls them web01 and web02.

Replace rundeck.your_domain and the example IP addresses with your own values.

Step 1 - Installing Java

Rundeck 6 runs on Java 17, 21 or 25. Install the Java 21 runtime from the Ubuntu repositories:

sudo apt update
sudo apt install openjdk-21-jre-headless

Check the version:

java -version
openjdk version "21.0.8" 2025-07-15
OpenJDK Runtime Environment (build 21.0.8+9-Ubuntu-0ubuntu124.04.1)
OpenJDK 64-Bit Server VM (build 21.0.8+9-Ubuntu-0ubuntu124.04.1, mixed mode, sharing)

Step 2 - Installing Rundeck from the official repository

Download the repository signing key into /etc/apt/keyrings:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://packages.rundeck.com/pagerduty/rundeck/gpgkey | sudo gpg --dearmor -o /etc/apt/keyrings/rundeck.gpg

Add the repository, restricted to that key:

echo "deb [signed-by=/etc/apt/keyrings/rundeck.gpg] https://packages.rundeck.com/pagerduty/rundeck/any/ any main" | sudo tee /etc/apt/sources.list.d/rundeck.list

Install the server and the rd command-line client:

sudo apt update
sudo apt install rundeck rundeck-cli

Confirm what was installed:

apt policy rundeck | head -n 3
rundeck:
  Installed: 6.2.1.20260909-1
  Candidate: 6.2.1.20260909-1

The package creates a rundeck system user with its home in /var/lib/rundeck, puts configuration in /etc/rundeck and logs in /var/log/rundeck. The service is called rundeckd.

Step 3 - Configuring Rundeck before the first start

Rundeck ships with two demo accounts, admin:admin and user:user. Replace them before the service ever starts. Open the user file:

sudo nano /etc/rundeck/realm.properties

Find the account lines at the end of the file and change them so only the admin remains, with a strong password:

admin:your_strong_password,user,admin

This is a temporary clear-text password; in Step 5 you will replace it with a BCRYPT hash. Restrict the file so only root and the rundeck group can read it:

sudo chown root:rundeck /etc/rundeck/realm.properties
sudo chmod 640 /etc/rundeck/realm.properties

Next, tell Rundeck its public URL. It uses this value in links, emails, API responses and webhook URLs:

sudo sed -i 's|^grails.serverURL=.*|grails.serverURL=https://rundeck.your_domain|' /etc/rundeck/rundeck-config.properties
sudo sed -Ei 's|^(framework.server.url[[:space:]]*=).*|\1 https://rundeck.your_domain|' /etc/rundeck/framework.properties
grep -h 'serverURL\|framework.server.url' /etc/rundeck/rundeck-config.properties /etc/rundeck/framework.properties
grails.serverURL=https://rundeck.your_domain
framework.server.url = https://rundeck.your_domain

Finally, make Rundeck listen on localhost only and honor the X-Forwarded-* headers Nginx will send. On Debian and Ubuntu, JVM options go in /etc/default/rundeckd (do not edit /etc/rundeck/profile, which upgrades overwrite):

sudo nano /etc/default/rundeckd
RDECK_JVM_OPTS="-Dserver.address=127.0.0.1 -Drundeck.jetty.connector.forwarded=true"
RDECK_JVM_SETTINGS="-Xmx2g -Xms512m -server"

RDECK_JVM_SETTINGS sets the Java heap. Around half of the server's RAM is a reasonable starting point.

Enable and start the service. The first start takes one to two minutes while Rundeck creates its database:

sudo systemctl enable --now rundeckd
sudo tail -f /var/log/rundeck/service.log

Wait until the log shows a line similar to Grails application running at http://127.0.0.1:4440 in environment: production, then press CTRL+C. Check that the login page answers locally:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4440/user/login
200

Step 4 - Publishing Rundeck with Nginx and HTTPS

Install Nginx and Certbot:

sudo apt install nginx certbot python3-certbot-nginx

Create a server block:

sudo nano /etc/nginx/sites-available/rundeck
server {
    listen 80;
    listen [::]:80;
    server_name rundeck.your_domain;

    client_max_body_size 20m;

    location / {
        proxy_pass http://127.0.0.1:4440;
        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-Host $host;
        proxy_read_timeout 300s;
    }
}

Enable it, test and reload:

sudo ln -s /etc/nginx/sites-available/rundeck /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Allow web traffic and request a certificate:

sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d rundeck.your_domain

Port 4440 stays closed because Rundeck only listens on 127.0.0.1. Open https://rundeck.your_domain and log in as admin with the password from Step 3.

Step 5 - Hashing the admin password and getting an API token

Rundeck recommends BCRYPT hashes in realm.properties. In the web interface, click the gear icon in the top right and choose Password Utility. Select BCRYPT, type the admin password and copy the result, which starts with BCRYPT:.

Edit the user file again:

sudo nano /etc/rundeck/realm.properties

Replace the clear-text password with the hash:

admin:BCRYPT:$2a$10$your_generated_hash,user,admin

The default login module reads this file at startup, so restart Rundeck and log in again to confirm the password still works:

sudo systemctl restart rundeckd

Now create an API token for the rd client. Click the user icon, open Profile and, under User API Tokens, generate a new token for the admin role. Copy it, then export it together with the server URL in your shell on the Rundeck server:

export RD_URL=https://rundeck.your_domain
export RD_TOKEN=your_api_token

Test the connection:

rd projects list

The command should return an empty project list without errors. A 401 response means the token is wrong or was generated for a different server URL.

Step 6 - Preparing SSH access to the nodes

Rundeck runs commands on nodes over SSH as a regular user. Create a key pair owned by the rundeck user on the Rundeck server:

sudo install -d -o rundeck -g rundeck -m 700 /var/lib/rundeck/.ssh
sudo -u rundeck ssh-keygen -t ed25519 -N '' -C 'rundeck' -f /var/lib/rundeck/.ssh/id_ed25519
sudo cat /var/lib/rundeck/.ssh/id_ed25519.pub

On each node, create a dedicated account for Rundeck, add it to the adm group so it can read the system journal, and install the public key you just printed:

sudo adduser --disabled-password --gecos '' rundeck-exec
sudo usermod -aG adm rundeck-exec
sudo install -d -o rundeck-exec -g rundeck-exec -m 700 /home/rundeck-exec/.ssh
echo 'ssh-ed25519 AAAA...your_public_key... rundeck' | sudo tee /home/rundeck-exec/.ssh/authorized_keys
sudo chown rundeck-exec:rundeck-exec /home/rundeck-exec/.ssh/authorized_keys
sudo chmod 600 /home/rundeck-exec/.ssh/authorized_keys

The restart runbook needs to restart Nginx and nothing else. Grant exactly that command through a sudoers drop-in, and validate the file with visudo:

echo 'rundeck-exec ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx' | sudo tee /etc/sudoers.d/rundeck-exec
sudo chmod 440 /etc/sudoers.d/rundeck-exec
sudo visudo -cf /etc/sudoers.d/rundeck-exec
/etc/sudoers.d/rundeck-exec: parsed OK

Back on the Rundeck server, connect once to each node as the rundeck user. This confirms the key works and records the node's host key in /var/lib/rundeck/.ssh/known_hosts:

sudo -u rundeck ssh -i /var/lib/rundeck/.ssh/id_ed25519 [email protected] hostname

Answer yes to the host key prompt after checking the fingerprint. The command should print the node's hostname.

Step 7 - Creating a project and registering nodes

A project groups nodes, jobs and their history. Define the nodes in a YAML file that Rundeck reads as the project's node source:

sudo install -d -o rundeck -g rundeck -m 750 /var/lib/rundeck/nodes
sudo nano /var/lib/rundeck/nodes/ops.yaml
web01:
  nodename: web01
  hostname: 203.0.113.21
  username: rundeck-exec
  osFamily: unix
  description: Web server 1
  tags: web,production
web02:
  nodename: web02
  hostname: 203.0.113.22
  username: rundeck-exec
  osFamily: unix
  description: Web server 2
  tags: web,production
sudo chown rundeck:rundeck /var/lib/rundeck/nodes/ops.yaml

Create the project with rd. The properties after -- set the SSH key for the whole project and register the YAML file as its node source:

rd projects create -p ops -- \
  --project.label="Operations" \
  --project.ssh-keypath=/var/lib/rundeck/.ssh/id_ed25519 \
  --resources.source.1.type=file \
  --resources.source.1.config.file=/var/lib/rundeck/nodes/ops.yaml \
  --resources.source.1.config.format=resourceyaml \
  --resources.source.1.config.requireFileExists=true \
  --resources.source.1.config.includeServerNode=true

List the nodes Rundeck now knows about:

rd nodes list -p ops
rundeck-server
web01
web02

Run an ad hoc command on every node tagged web to confirm SSH execution works end to end:

rd adhoc -p ops -F 'tags: web' -f -- uptime
 10:41:07 up 12 days,  3:02,  0 users,  load average: 0.08, 0.05, 0.01
 10:41:07 up 30 days,  1:17,  0 users,  load average: 0.21, 0.12, 0.09

Step 8 - Loading incident response jobs

A runbook is only useful if it is safe to run at 3 a.m. by someone who did not write it. The two jobs below follow that rule: the first only reads state, and the second performs one well-defined action, one node at a time, and checks the result.

Create the job file:

nano ~/incident-jobs.yaml
- name: Collect diagnostics
  group: incident
  description: Read-only snapshot of the web nodes during an incident.
  loglevel: INFO
  nodefilters:
    filter: 'tags: web'
    dispatch:
      threadcount: 4
      keepgoing: true
  options:
  - name: service
    description: systemd unit to inspect
    value: nginx
    required: true
    enforced: true
    values:
    - nginx
  sequence:
    keepgoing: true
    strategy: node-first
    commands:
    - description: Load, memory and disk usage
      exec: uptime && free -m && df -h -x tmpfs -x devtmpfs
    - description: Top processes by CPU
      exec: ps -eo pid,user,%cpu,%mem,etime,cmd --sort=-%cpu | head -n 15
    - description: Service state and recent warnings
      script: |-
        #!/usr/bin/env bash
        systemctl is-active '@option.service@' || true
        journalctl -u '@option.service@' --since '-30 min' -p warning --no-pager | tail -n 50

- name: Restart service
  group: incident
  description: Restart a web service on the selected nodes, one node at a time.
  loglevel: INFO
  nodefilters:
    filter: 'tags: web'
    dispatch:
      threadcount: 1
      keepgoing: false
  options:
  - name: service
    description: systemd unit to restart
    value: nginx
    required: true
    enforced: true
    values:
    - nginx
  sequence:
    keepgoing: false
    strategy: node-first
    commands:
    - description: Restart the service
      exec: sudo /usr/bin/systemctl restart ${option.service}
    - description: Verify it is running again
      exec: sleep 5 && systemctl is-active ${option.service}

Some notes on the format:

  • enforced: true with a values list means the option can only take the values you listed, so nobody can restart an arbitrary unit through this job.
  • Option values are referenced as ${option.name} in exec steps and as @option.name@ inside inline scripts.
  • threadcount: 1 and keepgoing: false make the restart job stop at the first node that fails instead of taking down the whole pool.

Load the file into the project:

rd jobs load -p ops -f ~/incident-jobs.yaml -F yaml

The command reports both jobs as created. Loading the same file again updates them in place, because Rundeck matches jobs by group and name.

Run the diagnostics job and follow its output:

rd run -p ops -j 'incident/Collect diagnostics' -f -- -service nginx

The same jobs appear in the web interface under Jobs, where you can run them with a form, see every past execution and read the output per node and per step.

Step 9 - Giving the on-call team access with an ACL policy

Rundeck grants permissions through .aclpolicy files. Any file in /etc/rundeck ending in .aclpolicy is loaded automatically, without a restart. This policy lets members of the oncall group see the ops project and its nodes, and run or stop only the jobs in the incident group:

sudo nano /etc/rundeck/oncall.aclpolicy
description: On-call engineers can run incident jobs in the ops project.
context:
  project: ops
for:
  resource:
    - equals:
        kind: node
      allow: [read]
    - equals:
        kind: event
      allow: [read]
  job:
    - equals:
        group: incident
      allow: [read, run, kill]
  node:
    - allow: [read, run]
by:
  group: oncall

---

description: On-call engineers can see the ops project.
context:
  application: rundeck
for:
  project:
    - equals:
        name: ops
      allow: [read]
by:
  group: oncall

Validate the policy:

sudo rd acl validate -f /etc/rundeck/oncall.aclpolicy
The validation passed

Create an on-call user in realm.properties. Generate the hash with Password Utility as before and give the account the user and oncall roles:

alice:BCRYPT:$2a$10$another_generated_hash,user,oncall

Restart Rundeck to load the new account, then log in as that user in a private window. alice sees the two incident jobs and can run them, but has no ad hoc command box and no access to other projects or to the system settings.

sudo systemctl restart rundeckd

Step 10 - Triggering a runbook from a webhook

Webhooks let your monitoring system start a job when an alert fires, so the diagnostics are already in Rundeck when the on-call engineer opens the alert. Webhooks run as an existing user, and that user must have logged in at least once.

In the ops project, open Webhooks in the left menu and create a new webhook:

  1. Name: alert-diagnostics.
  2. User and Roles: admin and admin (or a dedicated service user with its own ACL).
  3. Tick Use Authorization Header so the URL alone is not enough to trigger it.
  4. Webhook plugin: Run Job, then select incident/Collect diagnostics and set Options to -service nginx.
  5. Save. Copy the Post URL and the authorization string, which is shown only once.

Test it with curl:

curl -s -X POST 'https://rundeck.your_domain/api/50/webhook/your_webhook_token#alert-diagnostics' \
  -H 'Authorization: your_authorization_string' \
  -H 'Content-Type: application/json' \
  -d '{"alert": "HighLatency", "severity": "warning"}'
{"executionId":"12","jobId":"9bb310cf-fa0a-4a66-89a0-1892d73021e2"}

Use the exact Post URL that Rundeck displays, since the API version number in it depends on your Rundeck version. Fields from the JSON payload are available to the Run Job plugin as ${data.field}, for example -alert ${data.alert} if you add an alert option to the job.

Troubleshooting

rundeckd starts but the web interface never comes up. Read /var/log/rundeck/service.log. OutOfMemoryError means the heap in RDECK_JVM_SETTINGS is too small for the server's workload, and Address already in use means something else is on port 4440 (sudo ss -tlnp | grep 4440).

Links and redirects point to http://127.0.0.1:4440. grails.serverURL in /etc/rundeck/rundeck-config.properties is not set to your public URL, or the forwarded headers option is missing from /etc/default/rundeckd. Fix the value and restart rundeckd.

Jobs fail with Authentication failure on a node. Repeat the manual test from Step 6 with sudo -u rundeck ssh -i /var/lib/rundeck/.ssh/id_ed25519 rundeck-exec@node_ip hostname. If it works by hand, check that project.ssh-keypath points to the same key (rd projects configure get -p ops) and that the node's username in ops.yaml is rundeck-exec.

The restart step fails with sudo: a password is required. The command in the job must match the sudoers rule exactly, including the full path /usr/bin/systemctl.

A user sees an empty project list. Their roles in realm.properties do not match any by: group: in an ACL policy, or the application context section is missing. Validate every policy with sudo rd acl validate.

Conclusion

You installed Rundeck on Ubuntu 24.04 behind Nginx, locked down its accounts, connected it to your servers over SSH with a restricted user, and turned two incident procedures into audited, access-controlled jobs that can also be started by your alerting system. Next, store your job definitions in Git and load them with rd jobs load from CI, replace realm.properties with LDAP or SSO once more people need access, and move from the embedded database to MySQL or PostgreSQL before relying on Rundeck in production.