Skip to content

DTAP environments

Based on experience with successful OEM customers, a well-structured DTAP (Development, Test, Acceptance, Production) environment strategy significantly contributes to deployment success. This guide covers the recommended environment setup using Qlik Cloud spaces and version control.

Single tenant with space-based development, test, and acceptance environments

Recommended approach for OEM deployments:

For development, test, and acceptance environments, it is recommended to use a single Qlik Cloud tenant on a separate subscription from your production subscription. Within this tenant, use spaces to separate Development, Test, and Acceptance stages.

Why this approach:

  • Simplified management - Centralized administration and user management, typically your development team will need access to all development environments to collaborate on apps
  • Easy promotion - Apps move between spaces within the same tenant
  • Clear separation - Spaces provide logical boundaries between stages
  • Production isolation - Production runs on separate subscription with separate tenants per customer, this protects your production entitlements from being used by development or test environments

Architecture:

Non-Production Subscription:
└── Development Tenant
├── Shared Spaces (per project)
│ ├── Project A Development
│ ├── Project B Development
│ └── Project C Development
├── Managed Spaces
│ ├── Commit Space (commits published apps to version control, then deletes)
│ ├── Test Space (automated promotion from version control)
│ └── Acceptance Space (automated promotion once merged to main)
├── Version Control Integration (GitHub, GitLab, Azure DevOps)
└── Qlik Automate (for deployment automation, or use your own CI/CD tooling)
Production Subscription:
├── Customer A Tenant
├── Customer B Tenant
└── Customer C Tenant

Environment stages explained

Development (Shared Spaces)

Purpose: Active development and collaboration

  • One shared space per project/product
  • Developers build apps collaboratively
  • Rapid iteration with test data
  • App templates stored here

Test (Managed Space)

Purpose: Automated testing and validation

  • Managed space allows testing of core (approved) content, while permitting testers to maintain and verify their own sheets, bookmarks, visualizations, or other objects and configuration
  • Apps automatically promoted from test via version control
  • Integration testing with realistic data
  • App evaluation for performance testing - automatically run on each change to the app
  • Reviewer approval required before promotion to acceptance, this is a human verification gate controlled by merge to main branch

Acceptance (Managed Space)

Purpose: Final validation before production

  • Managed space for production-readiness review, this is a human verification gate before a release
  • Manual review and sign-off required
  • App evaluation to ensure performance - automatically run on each change to the app
  • Final approval gate before customer deployment

Production (Separate Subscription)

Purpose: Live customer environments

  • Separate tenant per customer
  • Apps deployed via Qlik Automate or your own CI/CD tooling
  • Production data and monitoring
  • Full isolation between customers
  • Limited or no interactive access for your development team

Overview: Version control-driven promotion

The recommended workflow uses app names as unique keys across spaces and version control, enabling automated promotion with human verification gates.

Key principles:

  • All steps automated except approval, merge, and deployment (human verification required)
  • Apps identified by name across all spaces
  • Version control (GitHub/GitLab/Azure DevOps) tracks all changes
  • Diffs in version control reveal changes not visible in the UI
  • App evaluation ensures performance at each stage
Video preview

Step-by-step workflow

Step 1: Development in shared spaces

Developers work in shared spaces per project:

// Development space structure
Development Tenant
├── Shared Space: "Customer Analytics Project"
│ ├── Product A Historical Dashboard
│ ├── Product A Monthly Performance Dashboard
│ └── Product A Live Wallboard
└── Managed Space: "Commit" (commits to version control, then deletes app)

Development process:

  1. Developers collaborate on apps in the shared development space
  2. Apps use test data and variable-based configuration
  3. When a new version is ready, developer publishes the app to the “Commit” managed space
  4. Publishing to the Commit space automatically triggers the version control workflow
  5. Once committed to version control, the app is automatically deleted from the Commit space
  6. App name is used as the unique identifier throughout the process

Purpose of the Commit space:

  • Space description: “A space that commits the published version of your app immediately to version control. The app will be deleted from the Commit space once it has been committed.”
  • Acts as a trigger point for automation
  • Transient storage only - apps are not retained here
  • Keeps the space clean and purpose-focused

Step 2: Automated version control commit

Automatically push apps to version control:

You can use a Qlik Automate workflow to automatically push apps to GitHub, GitLab, or Azure DevOps when ready for promotion. You can also use your own CI/CD tooling to automate the process by registering a webhook with Qlik Cloud on the app publish event.

// Qlik Automate: Push to version control
Trigger: App published to "Commit" managed space
Actions:
1. Export app from Commit space
2. Extract app metadata and structure
3. Create/update branch in version control (e.g., GitHub)
4. Commit changes with app name as key
5. Create pull request for review
6. Delete app from Commit space (keep space clean)
7. Automatically deploy to Test space

Benefits of version control:

  • Diff tracking - See exactly what changed in the app structure, load scripts, and objects
  • Small changes visible - Version control diffs catch changes not obvious in the Qlik UI
  • Audit trail - Complete history of who changed what and when
  • Rollback capability - Easy revert to previous versions
  • Code review - Team can review changes before promotion

Example: GitHub Actions unbuild workflow

One effective pattern for version control integration is to let GitHub (or your CI/CD platform) handle the unbuild step. When a developer exports a .qvf and commits it, a workflow runs that imports it into a temporary tenant, unbuilds it into its component files (load scripts, app properties, objects, images), and commits those components back to the branch. This gives reviewers a meaningful diff in the pull request rather than a binary file comparison.

Benefits of this approach:

  • Reviewable diffs - load scripts, variables, and object definitions appear as text diffs in the PR
  • Customer-controlled compute - runs on the customer’s own GitHub Actions runners (or self-hosted runners)
  • Composable - uses qlik-cli below, but you can substitute the @qlik/api TypeScript library or raw REST calls to the Apps API to suit your toolchain
  • Structure flexibility - you control the directory layout and what gets extracted

Required GitHub configuration:

Secret / VariableTypeDescription
CLIENT_SECRETSecretOAuth client secret for the non-production tenant
TENANT_HOSTNAMEVariableTenant hostname (for example, your-tenant.eu.qlikcloud.com)
CLIENT_IDVariableOAuth client ID

The OAuth client needs apps:create, apps:export, apps:delete, and media read scopes on the non-production tenant.

Save as .github/workflows/unbuild-app.yml:

Click to expand: unbuild-app.yml

name: Unbuild Qlik Sense app
on:
pull_request:
types: [opened, synchronize]
branches: ["main"]
paths:
- 'apps/**/**.qvf'
workflow_dispatch:
permissions:
contents: write
jobs:
unbuild:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch main branch
run: git fetch origin main:main
- name: Get changed .qvf files
id: changed_files
run: |
CHANGED_FILES=$(git diff --name-only main...HEAD | grep '\.qvf$')
echo "Changed files: $CHANGED_FILES"
printf "changed_files<<EOF\n%s\nEOF\n" "$CHANGED_FILES" >> $GITHUB_ENV
- name: Download qlik-cli and unzip
run: |
wget https://github.com/qlik-oss/qlik-cli/releases/download/v2.25.0/qlik-Linux-x86_64.tar.gz -O qlik-cli.tar.gz
tar -xf qlik-cli.tar.gz
rm qlik-cli.tar.gz
chmod +x qlik
- name: Retrieve qlik-cli version
run: ./qlik version
- name: Create context in qlik-cli
run: |
./qlik context create veritenant \
--server ${{ vars.TENANT_HOSTNAME }} \
--oauth-client-id ${{ vars.CLIENT_ID }} \
--oauth-client-secret ${{ secrets.CLIENT_SECRET }} \
--server-type cloud
- name: Select context
run: ./qlik context use veritenant
- name: Config git
run: |
git config --global user.name "qlik[bot]"
git config --global user.email "qlikbot@users.noreply.github.com"
- name: Unbuild changed apps
run: |
shopt -s globstar nocaseglob
echo "${{ env.changed_files }}" | while IFS= read -r FILE; do
if [[ -f "$FILE" ]]; then
APP_NAME=$(basename "$(dirname "$FILE")")
APP_DIR="apps/$APP_NAME"
COMPONENT_DIR="$APP_DIR/components"
# Delete the existing components directory before recreating the diff
rm -rf "$COMPONENT_DIR"
cp "$FILE" ./import.qvf
IMPORT_RESPONSE=$(./qlik raw post v1/apps/import --body-file "./import.qvf")
APP_ID=$(echo "$IMPORT_RESPONSE" | jq -r '.attributes.id')
if [ -z "$APP_ID" ] || [ "$APP_ID" = "null" ]; then
echo "Error: Failed to import app for $FILE"
echo "$IMPORT_RESPONSE"
exit 1
fi
# Unbuild into the components directory
./qlik app unbuild --app "$APP_ID" --dir "$COMPONENT_DIR" --no-data
REPLACE_STRING="<appId>"
find "$COMPONENT_DIR" -type f | while read -r UBFILEA; do
sed -i "s/$APP_ID/$REPLACE_STRING/g" "$UBFILEA"
if [ "$(basename "$UBFILEA")" == "app-properties.json" ]; then
jq 'del(.qSavedInProductVersion)' "${UBFILEA}" > "${UBFILEA}.tmp" && mv "${UBFILEA}.tmp" "${UBFILEA}"
jq 'del(.qLastReloadTime)' "${UBFILEA}" > "${UBFILEA}.tmp" && mv "${UBFILEA}.tmp" "${UBFILEA}"
VERSION=$(jq -r '.description' "${UBFILEA}" | grep -o '{v[0-9]\+\.[0-9]\+\.[0-9]\+}' | sed 's/[{}]//g')
VERSION="${VERSION:-0.0.1}"
echo "{\"version\": \"$VERSION\"}" > "$APP_DIR/metadata.json"
fi
done
# Split dimensions, measures, and variables into individual files
chmod +x .github/workflows/scripts/extract-components.sh
.github/workflows/scripts/extract-components.sh "$COMPONENT_DIR"
# Download embedded images
MEDIA_LIST=$(./qlik raw get "v1/apps/$APP_ID/media/list" --query "show=recursive")
IMAGE_LIST=$(echo "$MEDIA_LIST" | jq '[.[] | select(.type == "image") | {name: .name, link: .link}]')
IMAGE_STORE_PATH="$COMPONENT_DIR/images"
echo "$IMAGE_LIST" | jq -c '.[]' | while read -r IMAGE; do
mkdir -p "$IMAGE_STORE_PATH"
IMG_NAME=$(echo "$IMAGE" | jq -r '.name')
IMG_LINK=$(echo "$IMAGE" | jq -r '.link')
IMG_PATH="${IMG_LINK#/api/}"
./qlik raw get "$IMG_PATH" --output-file "$IMAGE_STORE_PATH/$IMG_NAME"
done
./qlik app rm "$APP_ID"
fi
done
- name: Remove qlik-cli
run: rm qlik
- name: Commit /components & version if changes
run: |
if ls ./apps/*/components 1> /dev/null 2>&1; then
git add ./apps/*/components
git add ./apps/*/metadata.json
GIT_STATUS=$(git status -uno -s)
if [ "$GIT_STATUS" ]; then
git commit -m "Unbuilt .qvf files into components"
git push
else
echo "No changes found in /components, skipping commit"
fi
else
echo "No components directories found, skipping commit"
fi

The workflow calls a helper script, extract-components.sh, which splits the consolidated dimensions.json, measures.json, and variables.json files output by app unbuild into one file per object. This makes individual dimension and measure changes visible as targeted diffs rather than a single large file modification. It also removes connections.yml, which contains environment-specific connection references that should not be version-controlled.

Save as .github/workflows/scripts/extract-components.sh:

Click to expand: extract-components.sh

#!/bin/bash
# Extract dimensions, measures, and variables into separate files
# Usage: ./extract-components.sh <components_directory>
COMPONENT_DIR="$1"
if [ -z "$COMPONENT_DIR" ]; then
echo "Error: Please provide components directory path"
exit 1
fi
# Extract dimensions, measures, and variables into separate files
for OBJECT_TYPE in dimensions measures variables; do
OBJECT_FILE="$COMPONENT_DIR/${OBJECT_TYPE}.json"
if [[ -f "$OBJECT_FILE" ]]; then
OBJECT_DIR="$COMPONENT_DIR/$OBJECT_TYPE"
mkdir -p "$OBJECT_DIR"
# Read the array and process each object
jq -c '.[]' "$OBJECT_FILE" | while IFS= read -r OBJECT; do
QID=$(echo "$OBJECT" | jq -r '.qInfo.qId')
if [ "$OBJECT_TYPE" = "variables" ]; then
QNAME=$(echo "$OBJECT" | jq -r '.qName // empty')
if [ -n "$QNAME" ] && [ "$QNAME" != "null" ]; then
SAFE_NAME=$(echo "$QNAME" | sed 's/[^a-zA-Z0-9]/_/g')
FILENAME="${SAFE_NAME}-${QID}.json"
else
FILENAME="${QID}.json"
fi
else
TITLE=$(echo "$OBJECT" | jq -r '.qMetaDef.title // empty')
if [ -n "$TITLE" ] && [ "$TITLE" != "null" ]; then
SAFE_TITLE=$(echo "$TITLE" | sed 's/[^a-zA-Z0-9]/_/g')
FILENAME="${SAFE_TITLE}-${QID}.json"
else
FILENAME="${QID}.json"
fi
fi
echo "$OBJECT" | jq . > "$OBJECT_DIR/$FILENAME"
done
# Remove the original consolidated file
rm "$OBJECT_FILE"
fi
done
# Remove connections.yml — connection names are environment-specific and should not be version-controlled
if [[ -f "$COMPONENT_DIR/connections.yml" ]]; then
rm "$COMPONENT_DIR/connections.yml"
echo "Removed connections.yml"
fi
echo "Component extraction completed for $COMPONENT_DIR"

How it works:

  1. On every PR that touches a .qvf file, the workflow identifies which app files changed
  2. Each .qvf is imported into the non-production tenant using the Apps API
  3. qlik-cli’s app unbuild command extracts the app into individual JSON and YAML files - one per object, sheet, variable, load script, etc.
  4. The temporary appId is replaced with a <appId> placeholder so it doesn’t create noise in future diffs
  5. Volatile fields (qSavedInProductVersion, qLastReloadTime) are stripped from app-properties.json
  6. extract-components.sh splits master dimensions, measures, and variables into individual named files, and removes connections.yml
  7. Any embedded images are downloaded and stored alongside the components
  8. The components are committed back to the PR branch - reviewers now see text diffs for every changed object

Resulting repository structure:

apps/
└── Product A Historical Dashboard/
├── Product A Historical Dashboard.qvf ← binary, triggers the workflow
├── metadata.json ← version extracted from app description
└── components/
├── app-properties.json
├── load-script.qvs
├── sheets/
│ ├── sheet-1.json
│ └── sheet-2.json
├── dimensions/
│ └── Sales_by_Region-ABC123.json ← one file per master dimension
├── measures/
│ └── Total_Revenue-DEF456.json ← one file per master measure
├── variables/
│ └── vEnvironment-GHI789.json ← one file per variable
└── images/
└── logo.png

Step 3: Test space promotion and review

Apps automatically deployed to Test managed space:

// Test space after automated promotion
Development Tenant
└── Managed Space: "Test"
├── Product A Historical Dashboard (from version control)
├── Product A Monthly Performance Dashboard (from version control)
└── Product A Live Wallboard (from version control)

Testing process:

  1. Automated deployment - App automatically appears in Test space from version control workflow
  2. App evaluation - Run app evaluation to establish performance baseline and identify issues
  3. Integration testing - Testers validate functionality with realistic data
  4. Performance validation - Ensure no performance degradation
  5. Review pull request - Team reviews version control diffs
  6. Approve or reject - Human approval required to proceed

Use App Evaluation feature:

// App evaluation in Test space
- Check app performance metrics
- Identify slow-loading sheets
- Detect calculation bottlenecks
- Compare against baseline performance
- Flag any degradations introduced

Learn more about App Evaluation

Step 4: Merge to main and Acceptance promotion

Human verification: Merge pull request

After successful testing and approval:

  1. Reviewer approves PR - Human decision gate
  2. Merge to main branch - Human action in version control
  3. Automated promotion to Acceptance - Workflow triggered by merge
  4. App appears in Acceptance space - Ready for final review
// Acceptance space after merge to main
Development Tenant
└── Managed Space: "Acceptance"
├── Product A Historical Dashboard (from main branch)
├── Product A Monthly Performance Dashboard (from main branch)
└── Product A Live Wallboard (from main branch)

Acceptance review process:

  1. Manual review required - Stakeholders review the app
  2. App evaluation - Final performance verification
  3. Sign-off - Formal approval to deploy to production
  4. Production readiness - Confirm app is ready for customers

Step 5: Production deployment via Automate

Human verification: Deploy to customer tenants

After acceptance sign-off:

  1. Human triggers deployment - Manual action to deploy
  2. Qlik Automate workflow - Deploys to customer tenants
  3. Stepped rollout - Pilot → Limited → Broad → Full
  4. Monitor deployments - Track success across tenants
// Qlik Automate: Deploy to production
Trigger: Manual approval
Actions:
1. Get app from Acceptance space or repository release (by app name)
2. Export app without data
3. Deploy to customer tenants (stepped rollout)
- Pilot customers (1-3 tenants)
- Limited rollout (10-20%)
- Broad rollout (50%)
- Full rollout (100%)
4. Trigger reloads with customer-specific variables
5. Monitor and alert on any failures

Data connection best practices across environments

Use relative data connection names

Recommended approach: Use relative data connection names so apps work seamlessly across Development, Test, and Acceptance spaces without modification.

Why this matters:

  • Apps can move between spaces without updating connection paths
  • No need to modify data connection references during promotion
  • Consistent connection names across all environments
  • Reduces deployment complexity and errors

Example: Relative connection naming

Omitting a space name and using a single colon (:) prior to the connection name will look for a data connection in the same space that the app is stored in.

// In your load script, use the same connection name across all environments
LOAD * FROM [lib://:ProductADataConnection/sales.parquet] (parquet);

Setup: Create data connections with the same name in each space:

  • Development space: “ProductADataConnection” → points to dev data
  • Test space: “ProductADataConnection” → points to test data
  • Acceptance space: “ProductADataConnection” → points to acceptance data
  • Production space: “ProductADataConnection” → points to production data (for the customer tenant)

The app uses lib://:ProductADataConnection/ everywhere, and it automatically connects to the right environment’s data by using the connection definition in the space.

Dynamic connection paths with GetSysAttr()

If consistent connection naming across spaces is not an option, use GetSysAttr() to detect which space the app is running in and branch accordingly:

LET vSpaceName = GetSysAttr('spaceName');
LET vTenantName = GetSysAttr('tenantName');
LET vTenantID = GetSysAttr('tenantId');
LET vTenantRegion = GetSysAttr('tenantRegion');
IF '$(vSpaceName)' = 'Test' THEN
LET vDataConnection = 'lib://TestData';
ELSEIF '$(vSpaceName)' = 'Acceptance' THEN
LET vDataConnection = 'lib://AcceptanceData';
ELSEIF Index('$(vSpaceName)', 'Development') > 0 THEN
LET vDataConnection = 'lib://DevData';
ELSE
// Production deployment (customer tenant)
LET vDataConnection = 'lib://ProductionData';
END IF
// Log environment details for troubleshooting
TRACE Running on tenant: [$(vTenantName)] [$(vTenantID)] region: [$(vTenantRegion)] space: [$(vSpaceName)].;
LOAD * FROM [$(vDataConnection)/sales.parquet] (parquet);

The TRACE statements write to the reload log and are useful for diagnosing environment mismatches. For the full list of available GetSysAttr() values, see the GetSysAttr() documentation.

Tag connections with the release version

When deploying data connections to customer tenants, tag each connection with the release version used to create it. This makes it straightforward to identify and remove connections that belong to an older release during monthly maintenance.

Use the Data Connections API to set tags at deployment time:

Terminal window
# Create a connection tagged with the release version
curl "https://<TENANT>/api/v1/data-connections" ^
-X POST ^
-H "Authorization: Bearer <ACCESS_TOKEN>" ^
-H "Content-Type: application/json" ^
-d "{
\"datasourceID\": \"snowflake\",
\"qName\": \"ProductADataConnection\",
\"space\": \"<SPACE_ID>\",
\"connectionProperties\": {
\"server\": \"<HOST>\",
\"database\": \"<DATABASE_NAME>\",
\"schema\": \"<SCHEMA_NAME>\",
\"warehouse\": \"<WAREHOUSE_NAME>\",
\"username\": \"<USERNAME>\",
\"password\": \"<PASSWORD>\"
},
\"tags\": [\"release:v2.4.0\"]
}"

During maintenance, iterate all connections across tenants and remove any not tagged with the current release version. This eliminates accumulation of stale connections without needing to track individual connection IDs.

Next steps

With your environments configured, you’re ready to design your application structure.

Continue to next step:Structure your applications

Was this page helpful?