CI/CD Integration

Run the DeployYourApp CLI in a pipeline with an API key, handle signing keys and secrets, and fail builds on non-zero exit codes.


Deploying from CI means three things: authenticating with an API key instead of a browser, providing the signing key from a secret, and letting non-zero exit codes fail the job. This page covers all three, with complete configurations for GitHub Actions and GitLab CI.

The shape of a pipeline job

npm ci
npm run build
npm install -g @deploy-your-app/cli
dya login --api-key "$DEPLOY_YOUR_APP_API_KEY"
dya deploy --channel production --message "Automated deploy"

Four rules apply to every CI system:

  • Use dya login --api-key. Never dya login without flags — it opens a browser and waits for you to authorize it.
  • Never dya setup or dya init. They are the same command, they require a terminal on both stdin and stdout, and they exit 1 immediately in a pipeline.
  • Commit .dya/config.json, so the checked-out working directory is already linked to the app. A pipeline runs dya deploy directly and never re-initializes.
  • Build your web assets before dya deploy. The CLI bundles whatever is on disk.

What the CLI does without a TTY

Every prompt in dya deploy has a defined non-interactive answer, and each one is the answer that changes nothing:

Prompt Non-interactive behaviour
"Move them into .dya/ ?" (stray keys or a legacy .deployyourapp) Skipped silently — the warning is not even printed, and nothing moves. Keys keep resolving from wherever they are.
"Generate RSA-4096 signing + encryption keys now?" (no signing key found, and no publicKey pinned in the Capacitor config) Answered no, after the No signing key found warning. A key minted on a build agent would be unbacked-up and gone at the end of the job. With still no signing key, the deploy then hits the unsigned-deploy refusal below and exits 1 unless --allow-unsigned was passed.
The same, but the Capacitor config does pin a publicKey Not asked at all, on a TTY or off one. The offer is withdrawn: a fresh keypair here is one every installed app rejects, and an unsigned bundle is refused by every current client too — neither option installs. With still no signing key, the deploy hits the same unsigned-deploy refusal.
The 6-digit two-factor code at upload time Not prompted; the upload fails with TWO_FACTOR_CODE_REQUIRED.

Not every stop in dya deploy is a prompt, though. Two checks refuse outright rather than falling back to a default answer:

  • An unsigned deploy, with no signing key resolved anywhere in the project. Refused by default, exit 1. Unlike the checks above, this one has a flag: --allow-unsigned proceeds anyway, for a pipeline that genuinely wants the upload — a first deploy before any client build exists, or exercising the rest of the pipeline before a signing key is provisioned. See Signing keys in CI.
  • A signing key that is not the pinned one. No flag skips this one — see The signing key must be the one your apps trust.

A prompt counts as interactive only when both stdin and stdout are terminals, so piping dya deploy into tee on a developer machine takes the same paths a runner does. dya setup applies the same test up front and refuses to start at all when either stream is not a terminal — dya setup | tee setup.log is rejected rather than half-completed, because a wizard whose questions silently answer themselves is worse than one that stops:

✗ `dya setup` (and `dya init`, which is now the same command) is interactive, and needs a
  terminal on both stdin and stdout. In CI, script the individual commands instead
  (login, keys, deploy).

What CI needs from the repository

Two things have to be committed for a pipeline to work, and they are easy to lose together:

.dya/config.json     # appId, channel, path
.gitignore           # containing the .dya/* block below

dya keys generate writes this block to your .gitignore (as do dya setup and dya deploy when you let them move a stray private key into .dya/):

# DeployYourApp — keys and local state. Never commit these.
.dya/*
!.dya/config.json

It excludes the folder's contents and then re-includes the config. It has to be .dya/* rather than .dya/: git cannot re-include a file whose parent directory is excluded, so .dya/ would leave config.json ignored — silently, because the file still exists on the developer's machine — and CI would check out a project with no app ID or channel. dya deploy would then exit 1 with No project config found.

You do not have to check this by hand, or fix it by hand. Every command that writes the block replaces a .dya/ (or .dya, or /.dya/) line in the project's own .gitignore with .dya/* and says so —

⚠ .gitignore had `.dya/`, which hid .dya/config.json from git as well —
⚠ replaced with `.dya/*`, which ignores the same files but lets the project config be committed.

— leaving the rest of the file untouched. Then it puts the result to real git check-ignore. If .dya/config.json is still ignored, the rule that beat it is named with its file, line and pattern, wherever it lives:

✗ git still ignores .dya/config.json, so it will not be committed and CI will have no appId or channel.
✗ The rule beating it is `.dya/` — /home/ada/work/.gitignore:12 (a .gitignore in a directory above this project).
⚠ That file belongs to a directory above this project and was not touched. Narrow the rule there to `.dya/*`, or point it at the directory it was meant for.
⚠ Check it with: git check-ignore -v .dya/config.json

A parent directory's .gitignore, this clone's .git/info/exclude, and your global excludes file are all reported and none of them are edited — they are not this project's files. See The rule is checked against real git.

If git cannot be asked — not on PATH, not a repository, or too slow to answer — the write is reported with that caveat attached rather than as a clean result, because "no rule was found beating the block" and "nobody looked" are not the same sentence:

✓ Added to .gitignore: .dya/*, !.dya/config.json
⚠ Written, but not verified: git could not say whether .dya/config.json is still ignored.
⚠ That happens when git is not on PATH, this is not a git repository, or git did not answer in time.
⚠ A rule elsewhere may still be hiding it, which would leave CI with no appId or channel.
⚠ Check it once git is available: git check-ignore -v .dya/config.json

That is worth acting on rather than scrolling past: the failure it warns about — a config that is never committed and a pipeline with no app ID — surfaces in CI, not on the machine that saw the message.

Commit the repaired .gitignore, then git add -f .dya/config.json once, since git will not pick up a file it has been ignoring. Run this on a workstation rather than in the pipeline: both the repair and the check only happen on the commands that write the block, and a job that only runs dya deploy never reaches them.

A project that still uses the legacy .deployyourapp file at its root needs that committed instead — it is still read. Nothing writes it any more, so the first time a developer runs a command that updates the config, the project moves to .dya/config.json and that is what needs committing from then on.

Authenticating with an API key

Create a key in the dashboard under Settings > API Keys. The raw key is shown once and is not recoverable — store it in your CI secret store immediately.

The CLI reads no environment variable for credentials. There is no DYA_API_KEY; the key must be passed explicitly:

dya login --api-key "$DEPLOY_YOUR_APP_API_KEY"

The name of the shell variable is entirely your choice — DEPLOY_YOUR_APP_API_KEY is used throughout this page as a convention. Always quote the expansion so a malformed value cannot be re-parsed as flags.

dya login validates the key against the server before saving it, so an expired, revoked, or mistyped key fails the login step rather than the deploy step.

Key scopes

An API key carries a list of scopes, defaulting to * (everything the owning user can do). To scope a key down to deploying, grant exactly the permissions the pipeline uses:

Command Required scopes
dya deploy app:read, bundle:upload, channel:read, deployment:create
dya deploy --no-deploy app:read, bundle:upload
dya rollback app:read, channel:read, deployment:rollback
dya keys upload app:read, app:update
dya channels create app:read, channel:create

A missing scope fails with API key missing scope: <permission>. Scopes only narrow the key — the owning user's role is still enforced, so a key created by a viewer cannot upload regardless of its scopes.

Organization binding

An API key is bound to the organization it was created in, and using it against any other organization is rejected. Two consequences for CI:

  • If the key owner belongs to exactly one organization, the CLI auto-selects it and no extra step is needed.

  • If the key owner belongs to more than one, the CLI cannot pick one on a runner with no stored config. Add an explicit switch after login:

    dya login --api-key "$DEPLOY_YOUR_APP_API_KEY"
    dya orgs switch --slug your-org-slug
    

Runners usually start from a clean home directory, so the global config does not persist between jobs and the switch must run every time.

Two-factor policies

If the organization has "require two-factor for uploads" enabled, dya deploy needs a fresh 6-digit code, which it can only prompt for on a TTY. Uploads from CI will fail with TWO_FACTOR_CODE_REQUIRED. Deploy interactively, or turn the policy off for organizations that deploy from a pipeline.

Signing keys in CI

dya-signing-private.pem must be in the project when dya deploy runs. Without it, dya deploy refuses to upload and exits 1 before contacting the server at all — a pipeline that forgets the key fails on the deploy step itself, not on a later install. This is deliberate: every current client refuses any bundle it cannot verify, so an unsigned upload was never a working deploy, only one that used to look like it succeeded. Pass --allow-unsigned if the pipeline genuinely needs to upload without a key — see Deploy behavior and failure modes — but the result still will not install anywhere until a real deploy signs a later release.

Write it into .dya/, the same place the CLI looks first on a developer machine. Store the PEM as a CI secret; base64-encoding avoids newline mangling in secret stores that only handle single-line values:

# Locally, once:
base64 -w0 .dya/dya-signing-private.pem   # paste the output into your CI secret store

# In the job, before dya deploy:
mkdir -p .dya
printf '%s' "$DYA_SIGNING_KEY_B64" | base64 -d > .dya/dya-signing-private.pem
chmod 600 .dya/dya-signing-private.pem

mkdir -p is needed because .dya/ itself is not committed — only the config inside it is, and git does not create a directory for a file it is about to check out into an otherwise-ignored folder. In practice the checkout creates .dya/ for config.json, but mkdir -p costs nothing and makes the step work in a workspace that was cleaned differently.

Remove the key at the end of the job if the workspace is not discarded:

rm -f .dya/dya-signing-private.pem

Keys restored to the repository root instead of .dya/ still work — the CLI falls back to the web root and the Capacitor root, in that order. .dya/ is simply the location it writes and the one the .gitignore block already covers.

The signing key must be the one your apps trust

Restoring a signing key is not enough — it has to be the one pinned as publicKey in the Capacitor config. Before signing anything, dya deploy fingerprints both and refuses on a mismatch, exiting 1 with nothing uploaded:

✗ Refusing to deploy: this bundle would be signed with a key your apps reject.
    src-capacitor/capacitor.config.json  e2bbb0fc4a91d3e7  ← what installed apps verify against
    .dya/dya-signing-private.pem  59c5420cb8d7f102  ← what this deploy would sign with

In a pipeline this almost always means the secret holds the wrong key — a key generated on a developer machine after the release shipped, or a stale secret left over from a rotation that never finished. The upload it prevents would have looked entirely successful: the server accepts it, the dashboard shows a healthy release, and every device in the field rejects that bundle and every bundle after it, with no server-side fix.

There is no --force and no environment variable to set. That is the point: an override on the publishing command is exactly the thing that gets pasted into a pipeline once and then suppresses the check on every run afterwards, where nobody reads the output. The way past it is to make the check true rather than to silence it — either restore the private key for the pinned fingerprint into the secret store, or rotate properly on a workstation with dya setup, which takes typed consent and re-pins the config so the fingerprints genuinely match. Both leave the pipeline with nothing to override. See Key rotation.

dya-encryption-public.pem is a public key, so it needs no secret handling; dya keys generate uploads it alongside the signing public key, though dya deploy no longer reads it — bundles are never encrypted. Its counterpart, dya-encryption-private.pem, is offered for embedding in your app binary and must never be present on a CI runner or uploaded anywhere.

Note that the shipped .gitignore block excludes everything in .dya/ except the config, so committing the encryption public key means adding an explicit negation for it — or restoring it from a (non-secret) CI variable like the signing key.

Never store any private key in the repository, in an environment variable printed by a debug step, or in build logs.

Exit codes

Every dya command exits 0 on success and non-zero on failure. Usage errors from the argument parser and runtime failures both exit 1. --help and --version exit 0.

Both example systems below fail the job automatically on a non-zero exit:

  • GitHub Actions — a run step executes with bash -e, so the step and the job fail on the first non-zero command.
  • GitLab CI — the script block runs under set -e, with the same effect.

For a hand-rolled shell script, add set -euo pipefail at the top so a failed dya login does not silently fall through to dya deploy.

GitHub Actions

name: Deploy OTA update

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build web assets
        run: npm run build

      - name: Install the DeployYourApp CLI
        run: npm install -g @deploy-your-app/cli

      - name: Restore the signing key
        env:
          DYA_SIGNING_KEY_B64: ${{ secrets.DYA_SIGNING_KEY_B64 }}
        run: |
          mkdir -p .dya
          printf '%s' "$DYA_SIGNING_KEY_B64" | base64 -d > .dya/dya-signing-private.pem
          chmod 600 .dya/dya-signing-private.pem

      - name: Deploy
        env:
          DEPLOY_YOUR_APP_API_KEY: ${{ secrets.DEPLOY_YOUR_APP_API_KEY }}
        run: |
          dya login --api-key "$DEPLOY_YOUR_APP_API_KEY"
          dya deploy \
            --channel production \
            --version "1.0.$GITHUB_RUN_NUMBER" \
            --message "$GITHUB_SHA"

      - name: Remove the signing key
        if: always()
        run: rm -f .dya/dya-signing-private.pem

Add DEPLOY_YOUR_APP_API_KEY and DYA_SIGNING_KEY_B64 under Settings > Secrets and variables > Actions in the repository. Secrets are masked in logs, but the CLI never echoes them anyway.

To gate deploys on a manually created tag or a review, put the deploy job behind a GitHub environment with required reviewers, or trigger the workflow on release: [published] instead of push.

GitLab CI

stages:
  - build
  - deploy

variables:
  NODE_VERSION: "20"

build:
  stage: build
  image: node:20
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

deploy:
  stage: deploy
  image: node:20
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - npm install -g @deploy-your-app/cli
    - mkdir -p .dya
    - printf '%s' "$DYA_SIGNING_KEY_B64" | base64 -d > .dya/dya-signing-private.pem
    - chmod 600 .dya/dya-signing-private.pem
    - dya login --api-key "$DEPLOY_YOUR_APP_API_KEY"
    - dya deploy --channel production --version "1.0.$CI_PIPELINE_IID" --message "$CI_COMMIT_SHA"
  after_script:
    - rm -f .dya/dya-signing-private.pem

Define DEPLOY_YOUR_APP_API_KEY and DYA_SIGNING_KEY_B64 under Settings > CI/CD > Variables, both as Variable type and Masked. Mark them Protected as well only if the deploy runs exclusively on protected branches or tags — a protected variable is not exposed to jobs on unprotected refs, and the job would then fail at login.

Versioning

Omit --version — the default is the one to want in a pipeline. The version becomes the version in your web-root package.json plus a UTC build stamp, e.g. 1.4.0+20260804.143022. The stamp has second granularity, so back-to-back pipelines do not collide, and build metadata is ignored when versions are compared, so the bundle matches the native version your store build reports rather than racing ahead of it. A pipeline whose package.json has no version, or one that is not a bare x.y.z, fails before it builds anything. See Bundle versions come from package.json.

--version still overrides it, and accepts x.y.z where each part is one or more digits. A monotonic CI counter maps onto the patch position:

dya deploy --version "1.0.$GITHUB_RUN_NUMBER"     # GitHub Actions
dya deploy --version "1.0.$CI_PIPELINE_IID"       # GitLab CI

A pipeline cannot answer a prompt, so a deploy whose version is older than the channel's active bundle refuses and exits 1 without uploading. Pass --allow-older when that is deliberate. See Deploy refuses to move a channel backwards.

Versions are unique per app across platforms. A pipeline that deploys both a Capacitor and an Electron bundle must give them different versions, or the second upload fails with a conflict.

Channel strategies

A common setup deploys every merge to a beta channel and promotes to production on a tag:

- name: Deploy to beta
  if: github.ref == 'refs/heads/main'
  run: dya deploy --channel beta --version "1.0.$GITHUB_RUN_NUMBER"

- name: Deploy to production
  if: startsWith(github.ref, 'refs/tags/v')
  run: dya deploy --channel production --version "1.0.$GITHUB_RUN_NUMBER" --rollout 10

The channel must already exist — dya deploy does not create it, and a missing channel fails after the bundle has been uploaded. Create channels once with dya channels create --name beta, or from the dashboard.

To upload a build without releasing it, pass --no-deploy and point a channel at the bundle later from the dashboard.

Rate limits and payload limits

The server applies limits that a busy pipeline can reach:

Limit Value
Bundle uploads 30 per hour
General API requests 100 per minute
Bundle file size 500 MB

Exceeding a rate limit returns RATE_LIMIT_EXCEEDED and the command exits 1. Runners that share an egress IP share the budget, so a fleet of parallel pipelines can hit the upload limit sooner than the per-pipeline count suggests.

Troubleshooting

API key rejected by <url> — the key is wrong, revoked, expired, or belongs to a different server than the one the CLI is pointed at. Check --server.

No active organization. Run dya orgs switch first. — the key owner belongs to several organizations. Add dya orgs switch --slug <slug> after login.

This API key is not valid for this organization — the stored activeOrgId is not the organization the key was created in. Switch to the key's own organization.

This app has a signing key registered, but the upload is unsigned — the CLI found no dya-signing-private.pem anywhere in the project when dya deploy ran. Check the restore step, that it writes to .dya/, and that it runs in the same directory as the deploy.

Refusing to deploy: this bundle would be signed with a key your apps reject — the restored key is not the one pinned as publicKey in the committed Capacitor config. Compare the two fingerprints the CLI prints: the secret almost certainly holds a key generated after the release shipped. Restore the private key for the pinned fingerprint, or complete a rotation on a workstation. There is no flag to bypass this — see The signing key must be the one your apps trust.

No project config found. Run `dya setup --app-id <appId>` first..dya/config.json was not checked out. Almost always a .gitignore written as .dya/ instead of .dya/*, which ignores the config silently. Run dya keys generate on a developer machine: it replaces such a line in the project's own .gitignore with .dya/*, and then asks git whether the config is still ignored, naming the file and line of any rule it must not edit itself. Commit the .gitignore and git add -f .dya/config.json. Do not "fix" this by running dya init in the pipeline — it needs a terminal on stdin and stdout, and will exit 1.

Version <x> already exists — the version was already uploaded for this app. Use a counter that increments per pipeline run, or omit --version.

Channel "<name>" not found — the bundle uploaded but the deployment step failed. Create the channel, then re-run.

Web assets not found — no --path, no path in .dya/config.json, and no auto-detected directory containing index.html. Confirm the build step ran and produced output in the same job.


Previous
Configuration
Next
Setup