Render Tutorials
Render CLI for power users

Capstone - deploy on push, fail loudly, dump logs

⏱ 12 min

You’ve now got every piece. This step assembles them into the workflow you came for: a single .github/workflows/deploy.yml that ships your service on every push to main, waits for the deploy to finish, exits non-zero when it fails, and dumps the last 200 log lines into the GitHub workflow output so triage starts with evidence in hand.

The shape:

flowchart LR
  push["push to main"]
  install["Install the Render CLI (pinned)"]
  deploy["render deploys create --wait"]
  ok{"Deploy live?"}
  logs["render logs (last 200 lines)"]
  notify["Optional: post status to PR"]

  push --> install --> deploy --> ok
  ok -->|"yes"| notify
  ok -->|"no"| logs --> notify

Prerequisites

PrerequisiteHow
A Render web service or worker that autodeploys from main is not required - the workflow triggers deploys explicitlyAutodeploy can be either on or off; this workflow doesn’t care
RENDER_API_KEY saved as a GitHub Actions secretRepo -> Settings -> Secrets and variables -> Actions -> New secret
RENDER_SERVICE_ID saved as a GitHub Actions secret (or variable)Same place; the srv-... ID from render services -o json

The full workflow

.github/workflows/deploy.yml
name: Deploy to Render
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: render-deploy-${{ github.ref }}
cancel-in-progress: false
env:
RENDER_CLI_VERSION: "v2.7.0"
RENDER_OUTPUT: json
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install the Render CLI (pinned)
run: |
set -euo pipefail
version="${RENDER_CLI_VERSION#v}"
curl -sSfL \
"https://github.com/render-oss/cli/releases/download/${RENDER_CLI_VERSION}/cli_${version}_linux_amd64.zip" \
-o /tmp/render.zip
unzip -q /tmp/render.zip -d /tmp/render
sudo mv "/tmp/render/cli_${RENDER_CLI_VERSION}" /usr/local/bin/render
render --version
- name: Trigger deploy
id: deploy
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
SRV: ${{ secrets.RENDER_SERVICE_ID }}
run: |
set -euo pipefail
render deploys create "$SRV" \
--wait \
--confirm \
--commit "$GITHUB_SHA" \
> deploy.json
status=$(jq -r '.deploy.status' deploy.json)
deploy_id=$(jq -r '.deploy.id' deploy.json)
echo "status=$status" >> "$GITHUB_OUTPUT"
echo "deploy_id=$deploy_id" >> "$GITHUB_OUTPUT"
echo "::notice::Deploy $deploy_id reached status: $status"
- name: Dump recent logs on failure
if: failure()
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
SRV: ${{ secrets.RENDER_SERVICE_ID }}
run: |
echo "::group::Last 200 log lines"
render logs -r "$SRV" --start 30m --limit 200 || true
echo "::endgroup::"
- name: Summarize on PR (optional)
if: always()
env:
STATUS: ${{ steps.deploy.outputs.status || 'unknown' }}
DEPLOY_ID: ${{ steps.deploy.outputs.deploy_id || 'n/a' }}
run: |
{
echo "### Render deploy"
echo ""
echo "- Status: \`$STATUS\`"
echo "- Deploy ID: \`$DEPLOY_ID\`"
echo "- Commit: \`$GITHUB_SHA\`"
} >> "$GITHUB_STEP_SUMMARY"

That’s the whole thing. Drop it into a repo, commit, and the next push to main runs the deploy.

What each piece is doing

Concurrency

excerpt
concurrency:
group: render-deploy-${{ github.ref }}
cancel-in-progress: false

concurrency with cancel-in-progress: false means a second push during an active deploy queues behind the first instead of canceling it. Cancelled deploys leave services in weird intermediate states - queueing avoids that.

Pinning the CLI

excerpt
env:
RENDER_CLI_VERSION: "v2.7.0"

The version is declared once at the top so it shows up in git blame. Bump it in a dedicated PR when you’re ready to take a newer release.

--commit "$GITHUB_SHA"

excerpt
render deploys create "$SRV" \
--wait \
--confirm \
--commit "$GITHUB_SHA" \
> deploy.json

This is what makes the workflow correct. Without --commit, Render builds whatever HEAD points to now - which can be a different commit than the one that triggered the workflow if someone pushed in between. With --commit "$GITHUB_SHA", you always deploy the exact commit that triggered the job.

The exit code contract

excerpt
- name: Trigger deploy
id: deploy
...
- name: Dump recent logs on failure
if: failure()

render deploys create --wait exits non-zero on a failed deploy, which fails the step, which triggers the if: failure() step. No need to parse status strings or polling logic - the CLI does all of it for you.

Dumping logs on failure

excerpt
echo "::group::Last 200 log lines"
render logs -r "$SRV" --start 30m --limit 200 || true
echo "::endgroup::"

GitHub Actions’ ::group:: annotation collapses the section so the workflow log stays readable. || true means a log fetch that itself fails (e.g. API timeout) doesn’t mask the original deploy failure - you still want the workflow to exit with the deploy’s failure code.

Job summary

excerpt
{
echo "### Render deploy"
echo "- Status: \`$STATUS\`"
} >> "$GITHUB_STEP_SUMMARY"

$GITHUB_STEP_SUMMARY is GitHub Actions’ built-in “show this on the workflow summary page” mechanism. It renders Markdown, so you can link to the Render Dashboard, embed status badges, or just keep a single-line breadcrumb for the team.

What the team sees

On success:

Workflow summary
### Render deploy
- Status: `live`
- Deploy ID: `dep-xxxxxxxxxxxxxxxx`
- Commit: `a1b2c3d4e5f6...`

On failure:

Workflow summary
### Render deploy
- Status: `build_failed`
- Deploy ID: `dep-yyyyyyyyyyyyyyyy`
- Commit: `a1b2c3d4e5f6...`

Plus the workflow log gets a collapsible “Last 200 log lines” group from the failure step, so the person triaging starts with the actual error message instead of clicking through to the Render Dashboard.

Common extensions

Post a PR comment on failure

If your workflow triggers from PRs (not just pushes to main), you can post a comment with actions/github-script:

excerpt
- name: Comment on PR
if: failure() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Render deploy failed for `${{ github.sha }}`. See the workflow log for the last 200 lines.'
});

Slack notification

excerpt
- name: Notify Slack
if: always()
env:
WEBHOOK: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
STATUS: ${{ steps.deploy.outputs.status }}
run: |
curl -sS -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"Render deploy: \`$STATUS\` for \`$GITHUB_SHA\`\"}" \
"$WEBHOOK"

Put it after the deploy step so success and failure both notify; pair with if: failure() if you only want failure pings.

Two services, one workflow

Some apps deploy multiple Render services per push (a web + a worker, or staging + prod). Run the deploys in parallel with a matrix:

excerpt
strategy:
fail-fast: false
matrix:
service:
- { name: "api", id: "srv-xxxxxxxxxxxxxxxx" }
- { name: "worker", id: "srv-yyyyyyyyyyyyyyyy" }

Reference matrix.service.id in the deploy step. fail-fast: false keeps one failure from canceling the other - usually what you want.

Run a smoke test after deploy

When render deploys create --wait returns success, that means the service reports healthy on its healthCheckPath. For richer end-to-end checks (real HTTP requests, a known query), add a step after the deploy:

excerpt
- name: Smoke test
env:
BASE_URL: https://api.acme.onrender.com
run: |
set -euo pipefail
curl -fsS "$BASE_URL/healthz"
curl -fsS "$BASE_URL/api/version" | jq -e '.sha == "'$GITHUB_SHA'"'

Adjust the assertions to whatever your service exposes. A failing smoke test fails the workflow, which gives you a real end-to-end signal in CI.

Beyond GitHub Actions

The same pattern works in any CI system that can:

  • Run a shell script
  • Read a secret from somewhere
  • Mark a step as failed when a process exits non-zero

GitLab CI, Bitbucket Pipelines, CircleCI, Drone, Buildkite - the install + deploy + log-on-failure pattern translates directly. Steal the bash from inside the run: blocks and rewrite the YAML wrapper for your platform.

Where to go next

GoalTutorial / Skill
Make render.yaml itself more sophisticatedAdvanced Blueprint patterns
Build a long-running, durable background workflow on RenderExtend SF Pulse with Render Workflows
Hand the CLI to an AI assistantrender skills install, then connect the Render MCP server
Diagnose specific failing deploysThe render-debug skill (already installed if you ran render skills install in step 09)

The CLI is the floor, not the ceiling. Once it’s in your fingers, the higher-level Render tools (Blueprints, MCP, AI tooling) compose on top of it without changing how you work.

Your workflow runs `render deploys create $SRV --wait --confirm` (no `--commit` flag) and a teammate notices that two pushes to `main` in quick succession sometimes deploy the same commit twice. What's the most likely cause?

What you learned

  • Pin the CLI version in CI; declare it once at the top of the workflow for easy `git blame`
  • Use `--commit $GITHUB_SHA` so the workflow deploys the *exact* commit that triggered it, not whatever HEAD is now
  • `render deploys create --wait` propagates the deploy's success/failure into the step's exit code - no polling logic required
  • `if: failure()` + `render logs --start 30m --limit 200` puts the actual error in the workflow log, where the person triaging will find it first
  • `$GITHUB_STEP_SUMMARY` is a free 'show this on the summary page' surface - use it for status, links, anything triage-friendly
  • The pattern translates to any CI system: install + deploy + log-on-failure