
Build Preview Environments for Every Pull Request with the Sliplane API
Jonas ScholzPreview environments are one of those things that sound fancy but are surprisingly simple to set up. The idea: every pull request gets its own live URL, so reviewers can click around and test the actual running app instead of pulling the branch locally.
In this tutorial you'll build a GitHub Actions workflow that uses the Sliplane API to automatically create a preview environment when a PR is opened, post the URL as a comment, and tear it down when the PR is closed.
How it works
The workflow has two main jobs:
- PR opened → create a Sliplane service from the PR branch, wait for it to come up, post the URL as a PR comment
- PR closed → find the service by name and delete it
All of this happens through the Sliplane API. Sliplane gives every public service a managed domain out of the box, so you'll get a live URL immediately.
Prerequisites
- A Sliplane account with a server and project already set up
- Your app needs to be in a GitHub repository and deployable via Docker
- Basic familiarity with GitHub Actions
Step 1: Create a Sliplane API Token
Go to your Sliplane team settings and create a new API token with read-write access. Copy it somewhere safe — you'll only see it once.
Step 2: Find Your Project and Server IDs
You'll need two IDs from Sliplane:
- Project ID — visible in the URL when you open a project in the dashboard (e.g.,
project_abc123) - Server ID — visible in the URL when you open a server (e.g.,
server_xyz456)
You can also get them from the API:
# List your projects
curl https://ctrl.sliplane.io/v0/projects \
-H "Authorization: Bearer YOUR_API_TOKEN"
# List servers in a project
curl https://ctrl.sliplane.io/v0/projects/PROJECT_ID/servers \
-H "Authorization: Bearer YOUR_API_TOKEN"
Step 3: Add Secrets to Your GitHub Repository
Go to your repository → Settings → Secrets and variables → Actions and add:
| Secret | Value |
|---|---|
SLIPLANE_API_TOKEN | Your Sliplane API token |
Step 4: Create the Workflow
Create a file at .github/workflows/preview.yml in your repository:
name: Preview Environment
on:
pull_request:
types: [opened, reopened, closed]
env:
SLIPLANE_API: https://ctrl.sliplane.io/v0
PROJECT_ID: your_project_id
SERVER_ID: your_server_id
jobs:
preview:
runs-on: ubuntu-latest
steps:
- name: Create Preview
if: github.event.action == 'opened' || github.event.action == 'reopened'
id: create-preview
run: |
PREVIEW_NAME="preview-pr-${{ github.event.number }}"
BRANCH="${{ github.head_ref }}"
RESPONSE=$(curl -s -X POST "$SLIPLANE_API/projects/$PROJECT_ID/services" \
-H "Authorization: Bearer ${{ secrets.SLIPLANE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"name": "'"$PREVIEW_NAME"'",
"serverId": "'"$SERVER_ID"'",
"deployment": {
"url": "https://github.com/${{ github.repository }}",
"branch": "'"$BRANCH"'",
"autoDeploy": true
},
"network": {
"public": true,
"protocol": "http"
}
}')
echo "service_id=$(echo "$RESPONSE" | jq -r '.id')" >> $GITHUB_OUTPUT
echo "domain=$(echo "$RESPONSE" | jq -r '.network.managedDomain')" >> $GITHUB_OUTPUT
- name: Wait for Service to be Ready
if: github.event.action == 'opened' || github.event.action == 'reopened'
run: |
SERVICE_ID="${{ steps.create-preview.outputs.service_id }}"
for i in {1..60}; do
STATUS=$(curl -s "$SLIPLANE_API/projects/$PROJECT_ID/services/$SERVICE_ID" \
-H "Authorization: Bearer ${{ secrets.SLIPLANE_API_TOKEN }}" \
| jq -r '.status')
echo "Attempt $i: Status is $STATUS"
if [ "$STATUS" = "live" ]; then
echo "Service is ready!"
break
elif [ "$STATUS" = "failed" ]; then
echo "Service failed to start"
exit 1
fi
if [ $i -eq 60 ]; then
echo "Timeout waiting for service to be ready"
exit 1
fi
sleep 5
done
- name: Comment Preview URL
if: github.event.action == 'opened' || github.event.action == 'reopened'
uses: actions/github-script@v7
with:
script: |
const url = 'https://${{ steps.create-preview.outputs.domain }}';
const body = `Preview deployed at: ${url}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(c => c.body.includes('Preview deployed at:'));
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Delete Preview
if: github.event.action == 'closed'
run: |
SERVICE_ID=$(curl -s "$SLIPLANE_API/projects/$PROJECT_ID/services" \
-H "Authorization: Bearer ${{ secrets.SLIPLANE_API_TOKEN }}" \
| jq -r '.[] | select(.name == "preview-pr-${{ github.event.number }}") | .id')
if [ -n "$SERVICE_ID" ]; then
curl -X DELETE "$SLIPLANE_API/projects/$PROJECT_ID/services/$SERVICE_ID" \
-H "Authorization: Bearer ${{ secrets.SLIPLANE_API_TOKEN }}"
fi
Replace your_project_id and your_server_id in the env section with your actual IDs from Step 2.
What each part does
Create Preview — calls POST /v0/projects/{projectId}/services to spin up a new service from your PR branch. The autoDeploy: true flag means Sliplane will automatically redeploy whenever new commits are pushed to that branch. The response includes a managedDomain (e.g., preview-pr-42.sliplane.app) that we pass to the next steps.
You can also add environment variables or mount volumes to your preview service by including env and volumes fields in the service creation request. Check the API reference for the full schema.
Wait for Service — polls GET /v0/projects/{projectId}/services/{serviceId} every 5 seconds until the status is live. If it hits failed it exits immediately with an error, and after 5 minutes it times out.
Comment Preview URL — uses the GitHub API to post (or update) a comment on the PR with the live URL. Updating instead of creating a new comment keeps things tidy if the PR is closed and reopened.
Delete Preview — on PR close, lists all services in the project and finds the one matching the PR number by name, then calls DELETE /v0/projects/{projectId}/services/{serviceId} to remove it.
Step 5: Open a Pull Request
Push the workflow file to your main branch, then open a new PR. You should see the workflow kick off in the Actions tab and within a couple of minutes a bot comment like this will appear on the PR:
Preview deployed at: https://preview-pr-42.sliplane.app
When you merge or close the PR, the cleanup job runs and the service is deleted automatically.
Wrapping up
That's it — fully automatic preview environments with about 80 lines of YAML and the Sliplane API. Every reviewer gets a live URL to click through, and you don't have to think about cleanup.
The full Sliplane API reference has more you can do from here: environment variables per service, volume mounts, TCP/UDP services, and more. If you want custom domains on your previews instead of the managed ones, the API supports that too via POST /v0/projects/{projectId}/services/{serviceId}/domains.
Cheers,
Jonas