Checkly in CI/CD: Monitoring as Code with the Checkly CLI
Checkly checks defined through the UI are fine for small setups. They break down when you have a team: no version control, no code review, no way to track what changed when, and manual effort to keep checks in sync with your application.
The Checkly CLI solves this. You define checks as TypeScript or JavaScript files, commit them to your repo, and deploy them with checkly deploy. This guide covers the full workflow: setting up the CLI, defining checks as code, integrating with GitHub Actions, and using checks as deployment gates.
Monitoring as Code: The Concept
The idea is simple: treat your synthetic monitors the same way you treat your application code.
- Checks live in your repository alongside the code they test
- Changes go through pull request review
- CI deploys the checks when code merges
- Before deploying to production, CI runs the checks against staging and blocks the deploy if they fail
This means a broken login flow that your checks catch in CI never reaches production. It also means when you change your login page, you update the check in the same PR — the two stay in sync by default.
Setting Up the Checkly CLI
Installation
npm install --save-dev checkly
# or
npx checkly --version # Run without installingAuthentication
npx checkly loginThis opens a browser window for OAuth. After authenticating, the CLI stores credentials in ~/.checkly/credentials.json.
For CI environments (no browser), use API key auth:
export CHECKLY_API_KEY=your_api_key
export CHECKLY_ACCOUNT_ID=your_account_idGet both from Account Settings in the Checkly dashboard. Never commit these to your repo.
Initialize a Project
npx checkly initThis creates:
checkly.config.ts— project configuration__checks__/— directory for your check definitions
Project Structure
A typical Checkly-as-code project looks like this:
my-app/
├── src/ # Application code
├── __checks__/
│ ├── api/
│ │ ├── health.check.ts
│ │ ├── auth.check.ts
│ │ └── products.check.ts
│ ├── browser/
│ │ ├── login.spec.ts
│ │ └── checkout.spec.ts
│ └── alert-channels.ts
├── checkly.config.ts
└── package.jsonChecks live in __checks__/ by default (configurable). The naming convention *.check.ts for API checks and *.spec.ts for browser checks is recommended but not required.
checkly.config.ts
The project configuration file:
import { defineConfig } from 'checkly'
import { Frequency } from 'checkly/constructs'
export default defineConfig({
projectName: 'My App Monitoring',
logicalId: 'my-app-monitoring',
repoUrl: 'https://github.com/my-org/my-app',
checks: {
// Default settings inherited by all checks
activated: true,
muted: false,
runtimeId: '2024.02',
frequency: Frequency.EVERY_5M,
locations: ['us-east-1', 'eu-west-1'],
tags: ['my-app'],
alertChannels: [], // Set globally or per check
environmentVariables: [],
checkMatch: '**/__checks__/**/*.check.ts',
browserChecks: {
frequency: Frequency.EVERY_10M,
testMatch: '**/__checks__/**/*.spec.ts',
},
},
cli: {
runLocation: 'eu-west-1',
reporters: ['list'],
},
})Defining API Checks
// __checks__/api/health.check.ts
import { ApiCheck, AssertionBuilder } from 'checkly/constructs'
new ApiCheck('health-check', {
name: 'Health Endpoint',
activated: true,
frequency: 1, // Every 1 minute
locations: ['us-east-1', 'eu-west-1', 'ap-southeast-1'],
request: {
method: 'GET',
url: '{{BASE_URL}}/health',
assertions: [
AssertionBuilder.statusCode().equals(200),
AssertionBuilder.responseTime().lessThan(2000),
AssertionBuilder.jsonBody('$.status').equals('ok'),
],
},
})// __checks__/api/auth.check.ts
import { ApiCheck, AssertionBuilder } from 'checkly/constructs'
new ApiCheck('auth-token-check', {
name: 'Auth - Token Exchange',
frequency: 5,
locations: ['us-east-1', 'eu-west-1'],
request: {
method: 'POST',
url: '{{BASE_URL}}/api/auth/token',
headers: [
{ key: 'Content-Type', value: 'application/json' },
],
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: '{{CLIENT_ID}}',
client_secret: '{{CLIENT_SECRET}}',
}),
assertions: [
AssertionBuilder.statusCode().equals(200),
AssertionBuilder.responseTime().lessThan(1000),
AssertionBuilder.jsonBody('$.access_token').isNotNull(),
AssertionBuilder.jsonBody('$.expires_in').greaterThan(0),
],
},
})Defining Browser Checks
Browser checks reference Playwright spec files:
// __checks__/browser/login.spec.ts
import { test, expect } from '@playwright/test'
test('User can log in and reach dashboard', async ({ page }) => {
await page.goto(process.env.BASE_URL + '/login')
await page.fill('input[name="email"]', process.env.TEST_EMAIL!)
await page.fill('input[name="password"]', process.env.TEST_PASSWORD!)
await page.click('button[type="submit"]')
await page.waitForURL('**/dashboard')
await expect(page.locator('[data-testid="dashboard-header"]')).toBeVisible()
})// __checks__/browser/checkout.spec.ts
import { test, expect } from '@playwright/test'
test('User can add item to cart and reach checkout', async ({ page }) => {
// Assume user is logged in via stored auth state
await page.goto(process.env.BASE_URL + '/products')
// Add first product to cart
await page.click('[data-testid="product-card"]:first-child [data-testid="add-to-cart"]')
// Go to cart
await page.click('[data-testid="cart-icon"]')
await page.waitForURL('**/cart')
// Verify item in cart
await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1)
// Proceed to checkout
await page.click('[data-testid="checkout-button"]')
await page.waitForURL('**/checkout')
await expect(page.locator('[data-testid="order-summary"]')).toBeVisible()
})Local Development Workflow
Before deploying, test your checks locally:
# Run all checks against your local environment
npx checkly test --env BASE_URL=http://localhost:3000
# Run a specific check
npx checkly test __checks__/api/health.check.ts
# Dry run: validate check definitions without running them
npx checkly test --dry-run
# Run from a specific location
npx checkly test --location eu-west-1The test command runs checks once and reports results without affecting your live Checkly configuration. Use it during development to iterate on check logic.
Deploying Checks
Once checks are working locally:
# Deploy all checks to Checkly
npx checkly deploy
# Preview what would be deployed (no changes made)
npx checkly deploy --preview
# Force deploy even if there are conflicts
npx checkly deploy --forcecheckly deploy is idempotent. Run it as many times as you want. Existing checks are updated, new ones are created, and checks not present in code are left untouched (they're not deleted automatically — use checkly destroy if you want to remove checks).
GitHub Actions Integration
Basic CI Workflow
Add Checkly credentials as GitHub Actions secrets: CHECKLY_API_KEY and CHECKLY_ACCOUNT_ID.
# .github/workflows/checkly.yml
name: Checkly Monitoring
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
deploy-checks:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Deploy Checkly checks
run: npx checkly deploy
env:
CHECKLY_API_KEY: ${{ secrets.CHECKLY_API_KEY }}
CHECKLY_ACCOUNT_ID: ${{ secrets.CHECKLY_ACCOUNT_ID }}This deploys updated checks every time code merges to main.
Deployment Gate: Run Checks Before Promoting
The more powerful pattern: run your E2E checks against staging, and only promote to production if they pass.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy-staging:
runs-on: ubuntu-latest
outputs:
staging-url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
id: deploy
run: |
# Your deploy command here
echo "url=https://staging.myapp.com" >> $GITHUB_OUTPUT
run-e2e-checks:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Run Checkly E2E checks against staging
run: npx checkly test --env BASE_URL=${{ needs.deploy-staging.outputs.staging-url }}
env:
CHECKLY_API_KEY: ${{ secrets.CHECKLY_API_KEY }}
CHECKLY_ACCOUNT_ID: ${{ secrets.CHECKLY_ACCOUNT_ID }}
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
deploy-production:
needs: [deploy-staging, run-e2e-checks]
runs-on: ubuntu-latest
steps:
- name: Deploy to production
run: echo "Promoting staging to production..."
# Your production deploy command hereIf run-e2e-checks fails, deploy-production never runs. Your E2E checks are now the literal gate between staging and production. A failing login flow blocks the deploy automatically.
Deploy Checks Alongside Code
The cleanest setup: keep check deployment in your main deploy workflow, not a separate one:
- name: Deploy application
run: ./deploy.sh production
- name: Deploy Checkly checks
run: npx checkly deploy
env:
CHECKLY_API_KEY: ${{ secrets.CHECKLY_API_KEY }}
CHECKLY_ACCOUNT_ID: ${{ secrets.CHECKLY_ACCOUNT_ID }}
BASE_URL: https://myapp.com
- name: Smoke test production with Checkly
run: npx checkly test --location us-east-1 --env BASE_URL=https://myapp.com
env:
CHECKLY_API_KEY: ${{ secrets.CHECKLY_API_KEY }}
CHECKLY_ACCOUNT_ID: ${{ secrets.CHECKLY_ACCOUNT_ID }}This pattern: deploy the app, deploy the updated checks, then run a smoke test against production using the checks. If the smoke test fails, you know immediately post-deploy and can roll back.
Alert Channels as Code
Define alert channels in code too:
// __checks__/alert-channels.ts
import { SlackAlertChannel, EmailAlertChannel } from 'checkly/constructs'
export const slackChannel = new SlackAlertChannel('slack-alerts', {
name: 'Engineering Slack',
url: process.env.SLACK_WEBHOOK_URL!,
sendRecovery: true,
sendFailure: true,
sendDegraded: false,
})
export const emailChannel = new EmailAlertChannel('email-alerts', {
name: 'Engineering Email',
address: 'oncall@myapp.com',
sendRecovery: true,
sendFailure: true,
})Then reference them in checks:
import { slackChannel } from '../alert-channels'
new ApiCheck('critical-check', {
name: 'Critical API',
alertChannels: [slackChannel],
// ...
})Everything is code. Everything is in version control. Changes to alert routing go through pull requests.
Verifying Your Setup
After deploying:
# List all deployed checks
npx checkly ls
# Show check details
npx checkly ls --verbose
# Destroy all checks (careful — this deletes from Checkly)
npx checkly destroyIn the Checkly dashboard, you'll see all checks created by your CLI project labeled with the project name. They behave identically to UI-created checks — same scheduling, same alerts, same dashboards — but now they're version-controlled.
Common Pitfalls
Don't mix UI checks and code checks for the same service. Pick one approach per service. Mixing creates confusion about which is authoritative.
Always set logicalId uniquely. The logicalId in checkly.config.ts uniquely identifies your project. If two repos use the same ID, they'll conflict.
Environment variables in CI need to match what your checks expect. If your check references {{BASE_URL}}, make sure BASE_URL is set in your CI environment variables. Missing variables cause checks to fail with confusing errors.
checkly test is not checkly deploy. test runs checks once without saving them. deploy saves check definitions to Checkly. You need both in your workflow: test in CI as a gate, deploy on merge to update the live monitors.