CI/CD & Docker

GitHub Actions — preview on every PR

The official ngris-edge/deploy-action builds your app and deploys a preview to the Ngris edge on every pull request, then comments its URL back on the PR. Every push replaces it. Pull requests never change your production site: a PR's preview is not live, and only a push to your default branch deploys to production.

  • Previews are private by default: only members of your ngris account can open them. Make them public in the app's Settings → Visibility.
  • Once the app has an endpoint, its previews follow that main endpoint's protections (sign-in, firewall, rate limits, WAF, edge functions) and aren't cached at the edge. The app the action creates has no endpoint until you add one. See Protections on preview URLs.
  • Each pull request keeps one preview; keep closed in the pull_request types so a closed PR's preview is turned off.
Setup (once): create an API key under Settings → API keys (it starts with ngk_), add it as a repository secret named NGRIS_API_KEY, then drop in the workflow. The application is created automatically on the first run.

Inputs

  • api-key — your ngk_ key, from the NGRIS_API_KEY secret. Required.
  • dir — the build-output directory to serve: Vite/Astro → dist, CRA/SvelteKit-static → build, Next.js static export → out, Nuxt generate → .output/public, Hugo → public.
  • build-command — optional, e.g. npm ci && npm run build. Omit if a previous step builds.
  • app — the app to deploy to (defaults to the repo name); comment — post the URL on the PR (default true).
  • production — auto (default): only a push to the default branch deploys to production; true: this run deploys to production (refused on pull requests); false: always a preview.

Outputs preview-url and deploy-uuid. This deploys static / framework build output; for a managed backend, connect the repo in the dashboard and enable branch auto-preview instead. Full reference: the action README.

.github/workflows/ngris.yml
name: ngris
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]   # closed: turns the PR's preview off
  push:
    branches: [main]                                 # your default branch: deploys to production
permissions:
  contents: read
  pull-requests: write        # lets the action comment the URL
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - uses: ngris-edge/deploy-action@v1
        with:
          api-key: ${{ secrets.NGRIS_API_KEY }}
          build-command: npm ci && npm run build
          dir: dist

Need a live running app in CI instead (end-to-end tests against real webhooks)? Run a tunnel with ngris http during the job and comment its URL — see the Tunnels docs.

Kubernetes Operator

The recommended way to run Ngris on Kubernetes. Declare endpoints, domains, traffic policies, mTLS, and auth as native CRDs; the operator reconciles them against the Ngris API and renders the agent Pods for you. GitOps-friendly, ArgoCD-ready, no CLI on the cluster.

What you get

  • 9 CRDs — Tunnel, TunnelMatrix, NgrisDomain, NgrisEndpoint, NgrisCertificate, NgrisTrafficPolicy, NgrisClientCA, NgrisMTLSAttachment, NgrisAuthPolicy.
  • Drop-in compatibility with existing Ingress (set ingressClassName: ngris) and Gateway API HTTPRoute.
  • Cluster-scoped lifecycle — owner references tear down the agent Pod and Secret on kubectl delete tunnel. The upstream endpoint is kept by default (it may also be managed from the dashboard / Terraform / another cluster); opt into cascade with annotations.ngris.io/delete-endpoint-on-delete: "true" for ephemeral / per-PR previews.
  • Status conditions (Ready, EndpointSynced, AgentDeployed) for ArgoCD / Flux sync waits.
  • Prometheus metrics built in (ngris_reconcile_total, ngris_api_requests_total).

Install

Create the operator namespace and a Secret with your API token, then Helm install.

Expose a Service

Three lines of YAML to expose any in-cluster Service via Ngris. Apply, then watch kubectl get tunnel — when READY=True, status.publicURL holds the live URL.

Multi-environment with one CR

TunnelMatrix fans one template into N child Tunnels — one per environment, each with overrides.

Composable security — TrafficPolicy, mTLS, Auth

Each policy attaches to a Tunnel via endpointRef — independent lifecycles, multiple policies per Tunnel, no edits to the Tunnel CR itself. Phase-grouped rules mirror the request lifecycle.

Migrating from existing Ingress

Already have Ingress manifests? Set spec.ingressClassName: ngris and the operator translates each (host × path → Service) into a Tunnel CR. Lossy (no rate-limit / mTLS / auth via Ingress spec — switch to Tunnel + policies for those), but a one-line migration accelerator.

Gateway API

Modern clusters: set controllerName: ngris.io/gateway-controller on a GatewayClass, then write standard HTTPRoute YAML. RequestHeaderModifier / ResponseHeaderModifier filters auto-generate a sibling NgrisTrafficPolicy.

Source & full reference: github.com/ngris/ngris-operator — CRD reference, Helm values, status condition vocabulary, and the kind-based E2E test suite.
Install
# 1. Operator namespace + token Secret
kubectl create namespace ngris-operator-system

kubectl -n ngris-operator-system create secret generic ngris-operator-config \
  --from-literal=NGRIS_API="https://api.ngris.com" \
  --from-literal=NGRIS_TOKEN="$NGRIS_TOKEN"

# 2. Helm install
helm install ngris-operator oci://ghcr.io/ngris-edge/charts/ngris-operator \
  --namespace ngris-operator-system \
  --version 0.1.0

kubectl -n ngris-operator-system rollout status deploy/ngris-operator
Tunnel CR
apiVersion: ngris.io/v1alpha1
kind: Tunnel
metadata:
  name: hello-api
spec:
  serviceRef: { name: hello-api, port: 8080 }
  host: hello.example.com   # longest-suffix-match → parent NgrisDomain + subdomain
  protocol: http
  region: eu-fi
TunnelMatrix
apiVersion: ngris.io/v1alpha1
kind: TunnelMatrix
metadata: { name: hello-api }
spec:
  template:
    serviceRef: { name: hello-api, port: 8080 }
    protocol: http
    region: eu-fi
  environments:
    - { name: dev,     host: hello-dev.example.com }
    - { name: staging, host: hello-staging.example.com }
    - { name: prod,    host: hello.example.com, region: us-va }
TrafficPolicy + mTLS
apiVersion: ngris.io/v1alpha1
kind: NgrisTrafficPolicy
metadata: { name: anon-rate-limit }
spec:
  endpointRef: { name: hello-api }
  on_http_request:
    - type: rate-limit
      config: { requests_per_minute: 60, burst: 100, key: ip }
    - type: jwt-validation
      config: { jwks_url: "https://issuer/.well-known/jwks.json" }
  on_http_response:
    - type: add-headers
      config: { headers: { x-served-by: ngris } }
---
apiVersion: ngris.io/v1alpha1
kind: NgrisMTLSAttachment
metadata: { name: hello-api-mtls }
spec:
  endpointRef: { name: hello-api }
  mode: required
  caRefs: [{ name: internal-corp-ca }]

Docker & Kubernetes (manual)

Run Ngris in containers when you don't want the operator. Supports Docker Compose and Helm.

Docker Compose (Local Development)

Deploy both your app and tunnel in a single compose file.

Tip: The agent reads its token from the API_TOKEN environment variable. Keep it in a .env file locally and never commit it to version control.

Kubernetes with Helm (Production)

Deploy Ngris as a sidecar or standalone pod in Kubernetes clusters.

Option 1: Helm Chart (Recommended)

Add the Ngris Helm repository and install the agent with custom values.

Option 2: Manual Deployment YAML

Render the Deployment + ConfigMap + Secret yourself if you want full control over the manifests.

Option 3: Sidecar Deployment

Deploy Ngris as a sidecar container within your application pod for seamless integration and lifecycle management.

Sidecar Configuration Explained

  • Startup Sequence: The init container waits for the database, then the main application starts. The Ngris sidecar waits for the application's health endpoint before establishing the tunnel.
  • Network Sharing: Both containers share the same network namespace (default behavior), allowing the sidecar to access the main container via localhost.
  • Dynamic Subdomains: Environment variables allow dynamic subdomain naming based on pod name, shard, or other identifiers.
  • Resource Management: The sidecar has defined resource limits to prevent it from consuming excessive cluster resources.
  • Health Monitoring: Startup probe ensures the Ngris process is running, complementing the main application's readiness/liveness probes.

Benefits of Sidecar Pattern

  • Tight Lifecycle Coupling: The tunnel is automatically created when the pod starts and destroyed when the pod terminates.
  • Simplified Networking: No need for service discovery or DNS lookups - the sidecar connects directly to localhost.
  • Enhanced Security: Authentication tokens are scoped to the specific pod/deployment rather than cluster-wide.
  • Better Observability: Tunnel logs are co-located with application logs, making debugging easier.
  • Granular Control: Different deployments can have different tunnel configurations, regions, and access controls.

Advanced Sidecar Configuration

For complex scenarios, use a ConfigMap to manage Ngris configuration: mount it and run ngris --config <file>. Keep the token and the Basic auth password in Secrets. The agent runs one tunnel per process, so add an ngris container for each endpoint.

Security Considerations for Sidecars:
  • Always store authentication tokens in Kubernetes Secrets, never in plain text.
  • Consider using tools like HashiCorp Vault or AWS Secrets Manager for dynamic secret injection.
  • Implement network policies to restrict which pods can communicate with the Ngris sidecar.
  • Regularly rotate authentication tokens and monitor for unauthorized endpoint creation.
Debugging Tip: To inspect the Ngris sidecar, use kubectl exec -it <pod-name> -c ngris -- sh to access its shell. You can then run ngris inspect to list the requests the tunnel captured, or check logs directly.
docker-compose.yaml
# docker-compose.yaml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
    networks:
      - app-network
  tunnel:
    image: ghcr.io/ngris-edge/ngris:latest
    # The image's entrypoint is ngris: run the wait loop in a shell instead
    entrypoint: ["sh", "-c"]
    command:
      - |
        while ! nc -z app 3000; do
          echo 'Waiting for app to start...'
          sleep 2
        done
        exec ngris http app:3000 --url myapp.ngris.com
    environment:
      - API_TOKEN=${API_TOKEN}
      - AGENT_LOG_LEVEL=debug
    depends_on:
      - app
    networks:
      - app-network

networks:
  app-network:
    driver: bridge
Helm chart
# Install the ngris operator from GHCR — OCI, no `helm repo add` needed
helm install ngris-operator oci://ghcr.io/ngris-edge/charts/ngris-operator \
  --namespace ngris-operator-system --create-namespace \
  --version 0.1.0

# Configure the token Secret first (see the Operator setup above),
# then declare your tunnels/endpoints as CRDs:  kubectl apply -f tunnel.yaml
Manual deployment YAML
# k8s-ngris.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ngris-agent
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ngris-agent
  template:
    metadata:
      labels:
        app: ngris-agent
    spec:
      containers:
      - name: ngris
        image: ghcr.io/ngris-edge/ngris:latest
        # The entrypoint is ngris: run the tunnel defined in the mounted config
        args: ["--config", "/etc/ngris/config.yaml"]
        env:
        - name: API_TOKEN
          valueFrom:
            secretKeyRef:
              name: ngris-secrets
              key: auth-token
        - name: AGENT_REGION
          value: "us-va"
        volumeMounts:
        - name: config
          mountPath: /etc/ngris
          readOnly: true
      volumes:
      - name: config
        configMap:
          name: ngris-config
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ngris-config
data:
  config.yaml: |
    tunnel:
      type: http
      local_address: web-service:8080
      url: prod-api.ngris.com
      name: web-service
---
apiVersion: v1
kind: Secret
metadata:
  name: ngris-secrets
type: Opaque
data:
  auth-token: BASE64_ENCODED_TOKEN_HERE
Sidecar deployment
# deployment-with-sidecar.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-deployment
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      initContainers:
      - name: wait-for-db
        image: busybox
        command: ['sh', '-c', 'until nc -z myapp-db 5432; do echo "Waiting for database..."; sleep 2; done;']
      containers:
      - name: myapp
        image: myorg/myapp:latest
        ports:
        - containerPort: 3000
          name: http
        env:
        - name: DATABASE_URL
          value: "postgresql://user:pass@myapp-db:5432/mydb"
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 10
          periodSeconds: 5
      - name: ngris
        image: ghcr.io/ngris-edge/ngris:latest
        command:
        - sh
        - -c
        - |
          while ! wget -qO- http://localhost:3000/health >/dev/null 2>&1; do
            echo "Waiting for myapp to start..."
            sleep 2
          done
          ngris http 3000 \
            --url $(SUBDOMAIN).ngris.com \
            --name $(POD_NAME)
        env:
        - name: SUBDOMAIN
          value: myapp-${SHARD}
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: SHARD
          value: "prod"
        - name: API_TOKEN
          valueFrom:
            secretKeyRef:
              name: ngris-secrets
              key: auth-token
        - name: AGENT_LOG_LEVEL
          value: "info"
        resources:
          requests:
            memory: "64Mi"
            cpu: "100m"
          limits:
            memory: "128Mi"
            cpu: "200m"
Advanced sidecar with ConfigMap
# advanced-sidecar.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: ngris-config
data:
  config.yaml: |
    logging:
      level: debug
    observability:
      region: us-va
    tunnel:
      type: http
      local_address: localhost:3000
      url: myapp-prod.ngris.com
      name: primary
    traffic_policy:
      rate_limit: 100/1m
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-advanced
spec:
  selector:
    matchLabels:
      app: myapp-advanced
  template:
    metadata:
      labels:
        app: myapp-advanced
    spec:
      containers:
      - name: myapp
        # ... application container config ...
      - name: ngris
        image: ghcr.io/ngris-edge/ngris:latest
        # The entrypoint is ngris: run the tunnel defined in the mounted config.
        # Kubernetes fills in $(TUNNEL_PASSWORD) (8+ characters) from env.
        args: ["--config", "/etc/ngris/config.yaml",
               "--basic-auth", "admin:$(TUNNEL_PASSWORD)"]
        env:
        - name: API_TOKEN
          valueFrom:
            secretKeyRef:
              name: ngris-secrets
              key: auth-token
        - name: TUNNEL_PASSWORD
          valueFrom:
            secretKeyRef:
              name: myapp-secrets
              key: tunnel-password
        volumeMounts:
        - name: config
          mountPath: /etc/ngris
          readOnly: true
      volumes:
      - name: config
        configMap:
          name: ngris-config
Was this helpful?