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
| Prerequisite | How |
|---|---|
A Render web service or worker that autodeploys from main is not required - the workflow triggers deploys explicitly | Autodeploy can be either on or off; this workflow doesn’t care |
RENDER_API_KEY saved as a GitHub Actions secret | Repo -> 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
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
concurrency: group: render-deploy-${{ github.ref }} cancel-in-progress: falseconcurrency 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
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"
render deploys create "$SRV" \ --wait \ --confirm \ --commit "$GITHUB_SHA" \ > deploy.jsonThis 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
- 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
echo "::group::Last 200 log lines"render logs -r "$SRV" --start 30m --limit 200 || trueecho "::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
{ 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:
### Render deploy
- Status: `live`- Deploy ID: `dep-xxxxxxxxxxxxxxxx`- Commit: `a1b2c3d4e5f6...`On failure:
### 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:
- 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
- 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:
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:
- 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
| Goal | Tutorial / Skill |
|---|---|
Make render.yaml itself more sophisticated | Advanced Blueprint patterns |
| Build a long-running, durable background workflow on Render | Extend SF Pulse with Render Workflows |
| Hand the CLI to an AI assistant | render skills install, then connect the Render MCP server |
| Diagnose specific failing deploys | The 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.
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