Skip to content

SSH

Execute commands on remote servers via SSH.

DAG-Level Configuration

You can configure SSH settings at the DAG level to avoid repetition:

yaml
# DAG-level SSH configuration
ssh:
  user: deploy
  host: production.example.com
  port: 22
  key: ~/.ssh/deploy_key
  password: ${env.SSH_PASSWORD}  # Optional; prefer keys
  strict_host_key: true  # Default: true for security
  known_host_file: ~/.ssh/known_hosts  # Default: ~/.ssh/known_hosts
  shell: "/bin/bash -e"  # Optional: string or array syntax for shell + args (DAG-level only)
  timeout: 60s  # Connection timeout (default: 30s)

steps:
  # All SSH steps inherit DAG-level configuration
  - id: health_check
    run: curl -f http://localhost:8080/health
  - id: restart_app
    run: systemctl restart myapp
    depends: health_check

Step-Level Configuration

yaml
steps:
  - action: ssh.run
    with:
      user: ubuntu
      host: 192.168.1.100
      key: /home/user/.ssh/id_rsa
      shell: "/bin/bash -o pipefail"  # Step-level `with.shell` accepts string form
      command: echo "Hello from remote server"

Configuration

DAG-Level Fields

FieldRequiredDefaultDescription
userYes-SSH username
hostYes-Hostname or IP address
portNo22SSH port
keyNoAuto-detectPrivate key path (see below)
passwordNo-Password (not recommended)
strict_host_keyNotrueEnable host key verification
known_host_fileNo~/.ssh/known_hostsKnown hosts file path
shellNo-Shell for remote command execution (string or array, e.g., "/bin/bash -e" or ["/bin/bash","-e"])
timeoutNo30sConnection timeout (e.g., 30s, 1m)
bastionNo-Bastion/jump host configuration (see below)

Step-Level Fields

FieldRequiredDefaultDescription
userYes-SSH username
hostYes-Hostname or IP address
portNo22SSH port
keyNoAuto-detectPrivate key path (see below)
passwordNo-Password (not recommended)
strict_host_keyNotrueEnable host key verification
known_host_fileNo~/.ssh/known_hostsKnown hosts file path
shellNo-Shell for remote command execution (string form only; e.g., "/bin/bash -e")
timeoutNo30sConnection timeout
bastionNo-Bastion/jump host configuration

Note: Password authentication is supported at both DAG and step level, but key-based authentication is strongly recommended.

The SSH timeout field limits connection establishment. DAG-level and step-level timeout_sec cover the complete operation, including TCP dialing, SSH handshake, bastion setup, and remote command execution. When execution is canceled or reaches either deadline, Dagu closes the SSH transport so the step can finish promptly.

Shell Configuration

When shell is specified, commands are wrapped and executed through the shell on the remote server:

yaml
ssh:
  user: deploy
  host: app.example.com
  shell: ["/bin/bash", "-e"]  # Commands wrapped as: /bin/bash -e -c 'command' (DAG-level example)

steps:
  - run: echo $HOME && ls -la  # Shell features like pipes, variables work

Without shell, commands are executed directly without shell interpretation. Use shell when you need:

  • Shell variable expansion ($HOME, $PATH)
  • Command chaining (&&, ||, ;)
  • Pipes (|) and redirections (>, <)
  • Glob patterns (*.txt)

Shell Priority:

  1. Step-level SSH with.shell (string only)
  2. DAG-level SSH config shell (string or array)
  3. Step-level shell field on the step (string or array, acts as fallback for UX)

Specifying Shell Arguments

  • DAG-level ssh.shell or the step-level shell field accept either string or array syntax, which is parsed into the executable plus argument list.
  • Step-level SSH with.shell currently accepts string form only because the configuration map is decoded into a string. Use quoted strings such as "/bin/bash -eo pipefail" there.

Variable Expansion Behavior

Dagu expands only DAG-scoped variables (env, params, secrets, step outputs) before sending commands to the remote host. OS-only variables (e.g., $HOME, $USER, $PATH) are not expanded locally; they pass through unchanged, letting the remote shell resolve them. This applies regardless of whether shell is configured.

yaml
ssh:
  user: deploy
  host: app.example.com

env:
  - DEPLOY_BRANCH: main

steps:
  - run: |
      cd $HOME/app              # $HOME NOT expanded; remote shell resolves it
      git checkout ${env.DEPLOY_BRANCH}  # Expanded by Dagu (defined in DAG env)

This allows you to write shell scripts that use remote variables without Dagu replacing them:

yaml
steps:
  - action: ssh.run
    with:
      user: deploy
      host: app.example.com
      command: |
        for FILE in *.log; do
          echo "Processing $FILE"  # $FILE is preserved for the remote shell
        done

To emit a literal $ in SSH commands or with fields, escape it as \$. When shell is configured, the remote shell handles the escape; without shell, Dagu unescapes it before sending.

To use a local OS value in SSH commands, explicitly import it via the DAG-level env: block:

yaml
env:
  - LOCAL_HOME: ${HOME}  # Import local $HOME into DAG scope

steps:
  - action: ssh.run
    with:
      user: deploy
      host: app.example.com
      command: echo "Local home was ${env.LOCAL_HOME}, remote home is $HOME"

The same rule applies to SSH with fields (user, host, key, password, etc.). A reference like key: $HOME/.ssh/deploy_key will not expand $HOME because it is not DAG-scoped. Import it first:

yaml
env:
  - HOME_DIR: ${HOME}

ssh:
  user: deploy
  host: app.example.com
  key: ${env.HOME_DIR}/.ssh/deploy_key

The shell field controls whether POSIX shell expansion features (default values, parameter substitution such as ${VAR:-default}) are available; it does not affect whether OS variables are expanded.

SSH Key Auto-Detection

If no key is specified, Dagu automatically tries these default SSH keys in order:

  1. ~/.ssh/id_rsa
  2. ~/.ssh/id_ecdsa
  3. ~/.ssh/id_ed25519
  4. ~/.ssh/id_dsa

Bastion Host

Connect through a jump host:

yaml
ssh:
  user: deploy
  host: private-server.internal
  bastion:
    host: bastion.example.com
    user: jump-user
    key: ~/.ssh/bastion_key

steps:
  - run: hostname

Step-level bastion:

yaml
steps:
  - action: ssh.run
    with:
      user: deploy
      host: private-server.internal
      bastion:
        host: bastion.example.com
        user: jump-user
        key: ~/.ssh/bastion_key
      command: hostname

Bastion Fields

FieldRequiredDefaultDescription
hostYes-Bastion hostname
portNo22Bastion SSH port
userYes-Bastion username
keyNoAuto-detectBastion private key path
passwordNo-Bastion password

Multiple Commands

Multiple commands share the same step configuration:

yaml
steps:
  - id: remote_checks
    action: ssh.run
    with:
      user: deploy
      host: production.example.com
      key: ~/.ssh/deploy_key
      command:
        - systemctl status nginx
        - systemctl status myapp
        - df -h /var/log
    preconditions:
      - condition: "${env.ENV}"
        expected: "production"

Instead of duplicating the SSH executor with block, preconditions, retry_policy, env, etc. across multiple steps, combine commands into one step.

Important: Each command runs in a new SSH session, so:

  • Working directory resets to the user's home directory for each command
  • Environment variables set in one command don't persist to the next
  • Use absolute paths or combine commands with && if you need shared context

Security Best Practices

  1. Host Key Verification: Always enabled by default (strict_host_key: true)

    • Prevents man-in-the-middle attacks
    • Uses ~/.ssh/known_hosts by default
    • Only disable for testing environments
  2. Key-Based Authentication: Strongly recommended

    • Prefer keys over passwords at all times
    • Use dedicated deployment keys with limited permissions
    • Rotate keys regularly
  3. Known Hosts Management:

    bash
    # Add host to known_hosts before running DAGs
    ssh-keyscan -H production.example.com >> ~/.ssh/known_hosts

Running Commands on Multiple Hosts

To execute the same command across multiple hosts in parallel, use parallel.items with a sub-DAG. Each host gets its own DAG run with separate logs, status, and retry tracking.

Define both DAGs in a single file using --- as a document separator. The second document becomes a local DAG, resolved by name when dag.run references it.

yaml
# deploy.yaml
steps:
  - name: run-on-servers
    parallel:
      items:
        - server1.example.com
        - server2.example.com
        - server3.example.com
      max_concurrent: 2
    action: dag.run
    with:
      dag: ssh-command
      params:
        HOST: ${ITEM}
---
name: ssh-command

params:
  - HOST: ""

steps:
  - name: run
    action: ssh.run
    with:
      host: ${params.HOST}
      user: deploy
      key: ~/.ssh/id_rsa
      command: uptime

When the same command needs a different SSH user, pass that user as a normal parameter:

yaml
params:
  - SSH_USER: deploy

steps:
  - name: run-on-servers
    parallel:
      items:
        - server1.example.com
        - server2.example.com
      max_concurrent: 2
    action: dag.run
    with:
      dag: ssh-command
      params:
        HOST: ${ITEM}
        USER: ${params.SSH_USER}
---
name: ssh-command

params:
  - HOST: ""
  - USER: ""

steps:
  - name: run
    action: ssh.run
    with:
      host: ${params.HOST}
      user: ${params.USER}
      key: ~/.ssh/id_rsa
      command: uptime

See parallel.items for full fan-out options.

See Also

  • SFTP - File transfer via SFTP
  • Docker - Container execution
  • Shell - Local commands

Dagu is open source under the GNU General Public License v3.0.