Deploying an Application
This guide takes you through the happy path: from an empty folder in your team's manifest repository to a running application in GAP, using a Gappynator Application resource.
It focuses on the manifest itself. The surrounding CI/CD setup — the Dockerfile, pushing to ACR, the build workflow and the Argo CD Application — is covered in Deploy Your First App using Gappynator and Argo CD.
gap-api generates all of the files below and opens the pull requests for you. Use it if you can, and come back here when you need to understand or change what it produced.
Prerequisites
Before your application can be deployed you need:
- An onboarded namespace — see Namespace Onboarding
- A container image in ACR — see Pushing a Container Image
- Your team's manifest repository, named
<your-team>-kubernetes-manifests - Your team's
gap.io/owner,gap.io/cost-centerandgap.io/service-codevalues
Repository layout
Manifests live under apps/<app-name>/, with one directory per environment. This is the layout used across the manifest repositories today, and the one Argo CD expects:
apps/my-app/
├── common.jsonnet # values shared by every environment
├── README.md # a link back to the source repository
├── test/
│ ├── app.jsonnet # the test environment
│ └── params.json # written by CI — never edit by hand
└── prod/
├── app.jsonnet # the prod environment
└── params.json # written by CI — never edit by hand
Keep the directory name, the Application name, the image repository name and the source repository name identical. Every team follows this convention and a lot of tooling depends on it.
We use jsonnet for manifests so that the two environments can share values without duplicating the whole resource.
Shared values
Put everything that does not change between environments in common.jsonnet:
{
name:: 'my-app',
namespace:: 'team-example',
port:: 8080,
// Replace with your team's values
labels:: {
'gap.io/owner': 'team-example',
'gap.io/cost-center': '19-00000',
'gap.io/service-code': 'BSN0000000',
},
// Key Vault object name -> environment variable name
secrets:: [
{ name: 'my-app-db-username', envVar: 'DB_USERNAME' },
{ name: 'my-app-db-password', envVar: 'DB_PASSWORD' },
],
}
The application manifest
app.jsonnet builds the Application resource for one environment. Everything not listed here falls back to a Gappynator default.
local common = import '../common.jsonnet';
local params = import 'params.json';
local environment = 'test';
local keyVaultName = 'gtm-example-shr-en-test-kv';
local hostSuffix = 'apps-int.testgjensidige.io';
local application = {
apiVersion: 'gap.io/v1',
kind: 'Application',
metadata: {
name: common.name,
namespace: common.namespace,
labels: common.labels + {
environment: environment,
} + {
// Deployment provenance, filled in by CI
['github.gap.io/' + key]: params.github[key]
for key in std.objectFields(params.github)
},
},
spec: {
image: params.container_image, // [1]
port: common.port,
ingress: {
host: common.name + '.' + hostSuffix, // [2]
},
resources: { // [3]
requests: { cpu: '100m', memory: '384Mi' },
limits: { cpu: '800m', memory: '512Mi' },
},
autoscaling: { // [4]
minReplicas: 2,
maxReplicas: 4,
},
env: [
{ name: 'ENVIRONMENT', value: environment },
],
azure: { // [5]
secretProviderClass: [
{
name: common.name,
keyVaultName: keyVaultName,
secrets: common.secrets,
},
],
},
},
};
{
apiVersion: 'v1',
kind: 'List',
items: [application],
}
- Always take the image from
params.json. Never hardcode a tag — CI writes a tag pinned to a digest here after every build. - Setting
hostis enough — Gappynator creates the route for you. By default this is a Gateway APIHTTPRoute. - The defaults are intentionally small. Set values that match your application.
- Two to four replicas is the default and a good starting point. Note that setting
minReplicasequal tomaxReplicasdisables autoscaling entirely. - Key Vault secrets become environment variables in your container. Gappynator creates the
SecretProviderClassand the managed identity, and keeps the secrets in sync.
The prod variant is the same file with three values changed — the environment, the Key Vault and the host suffix:
local environment = 'prod';
local keyVaultName = 'gpm-example-shr-en-prod-kv';
local hostSuffix = 'apps-int.gjensidige.io';
Deployment parameters
params.json carries the image and the provenance of the build that produced it. Commit it once with empty values, then leave it alone — the deploy workflow rewrites it on every release.
{
"container_image": "",
"container_image_tag": "",
"github": {
"workflow_actor_username": "",
"repo_name": "",
"repo_commit_sha": "",
"manifest_repo_name": "",
"manifest_repo_commit_sha": ""
}
}
Because the github block is copied onto the resource as labels, you can always tell which commit of which repository, built by whom, produced what is running in the cluster.
Wire up delivery
Two things remain, both covered elsewhere:
- Add the
GAP Workflow Dispatchstep to your build workflow so it patchesparams.jsonafter each successful build — see Deploy Your First App using Gappynator and Argo CD. - Create an Argo CD
Applicationpointing atapps/my-app/testso the cluster is kept in sync — see Deploying with Argo CD.
Verify
Render the manifest locally before pushing:
jsonnet -J vendor apps/my-app/test/app.jsonnet
Once Argo CD has synced, check the resource and its aggregated status:
kubectl get application my-app -n team-example
kubectl describe application my-app -n team-example
The Ready condition summarises every resource Gappynator owns for your application, and is what Argo CD reports as the health status. If something is wrong, describe names the sub-resource that failed.
# What was actually generated
kubectl get deployment,service,hpa,pdb,servicemonitor -n team-example -l gap.io/name=my-app
kubectl get application my-app -o yaml shows what you committed, not the effective configuration. To see the defaults that were applied, inspect the generated Deployment.
Commonly added configuration
The manifest above is deliberately minimal. These are the additions teams make next, roughly in the order they need them.
Health probes. Gappynator does not add probes for you. For a Spring Boot application exposing Actuator on the metrics port — see Runtime-specific configuration for how to expose it:
livenessProbe: {
httpGet: { path: '/actuator/health/liveness', port: 'metrics' },
initialDelaySeconds: 45,
periodSeconds: 10,
failureThreshold: 3,
},
readinessProbe: {
httpGet: { path: '/actuator/health/readiness', port: 'metrics' },
initialDelaySeconds: 45,
periodSeconds: 10,
failureThreshold: 3,
},
Network access. Namespaces deny all traffic by default. Use accessPolicy to describe intent and let Gappynator translate it into a Cilium policy:
accessPolicy: {
outbound: {
rules: [
{ application: 'other-service', namespace: 'team-other' },
],
external: [
{ host: 'example-redis.redis.cache.windows.net', ports: [{ port: 6380 }] },
],
},
},
See Network Access Policies for the details, including the lower-level networkPolicies alternative.
Configuration files. Mount a ConfigMap, a secret or an Azure file share with filesFrom. The referenced resource must already exist:
filesFrom: [
{
configMap: 'my-app-config',
mountPath: '/application/config/application.yml',
subPath: 'application.yml',
},
],
Runtime-specific configuration
spec.runtime selects which OpenTelemetry auto-instrumentation is injected into your pod. It accepts java, dotnet, python, nodejs, go and other, and defaults to java. It does not affect resource sizing, probes or any other setting.
spec: {
runtime: 'dotnet',
}
Gappynator always adds a second container port named metrics on 8081, and by default scrapes Prometheus metrics from /actuator/prometheus on that port. Your application has to hold up its end of that contract — the sections below describe what each runtime needs to expose.
End users should never be able to reach your application metrics. Metrics endpoints must only be available to internal systems. Serving them on the separate metrics port, which is never exposed externally, is how we achieve that.
Java and Spring Boot
Spring Boot is the default runtime and needs no spec.runtime at all. Add the Actuator and Micrometer dependencies:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
Then expose health probes and metrics on port 8081, and enable graceful shutdown:
spring:
application:
name: my-app # Change this
management:
endpoints:
web:
exposure:
include: health,prometheus
endpoint:
health:
probes:
enabled: true
metrics:
tags:
application: ${spring.application.name}
server:
port: 8081 # Must match the port Gappynator names "metrics"
server:
port: 8080
shutdown: graceful
This gives you the three endpoints that Gappynator's defaults expect:
- Liveness probe —
/actuator/health/livenesson port 8081 - Readiness probe —
/actuator/health/readinesson port 8081 - Prometheus metrics —
/actuator/prometheuson port 8081
Spring Security must permit the Actuator endpoints. Normally you would not use .permitAll() here, but because these are served on port 8081, which is never exposed externally, it is safe:
@EnableWebSecurity
class SecurityConfig : GjeWebSecurityConfigurerAdapter() {
override fun authorizeRequests(http: HttpSecurity) {
http
.authorizeRequests()
.requestMatchers(
EndpointRequest.to("health", "prometheus")
).permitAll() /* OK to permit all, these are served on the internal port 8081 */
.antMatchers(
HttpMethod.GET, "/v1/your-endpoint/**"
).hasAuthority(GjensidigeRole.PRIVATPERSON.value()) /* Always authorize requests */
.anyRequest().denyAll() /* Deny non permitted requests */
}
}
Finally, add the gap.io/spring-boot annotation so that Gappynator adds a preStop hook. Together with server.shutdown: graceful above, this lets Tomcat drain in-flight requests and gives you zero-downtime deployments:
metadata: {
annotations: {
'gap.io/spring-boot': 'true',
},
},
.NET
Set dotnetRuntime to match your base image — linux-musl-x64 for Alpine-based images, linux-x64 otherwise. .NET applications usually expose health on /health rather than the Actuator paths:
spec: {
runtime: 'dotnet',
observability: {
opentelemetry: {
autoInstrumentation: {
enabled: true,
dotnetRuntime: 'linux-musl-x64',
},
},
},
env: [
{ name: 'ASPNETCORE_URLS', value: 'http://*:8080;http://*:8081' },
],
livenessProbe: { httpGet: { path: '/health', port: 'metrics' } },
readinessProbe: { httpGet: { path: '/health', port: 'metrics' } },
},
Python, Node.js and Go
Point observability.prometheusMetrics at wherever your application actually exposes metrics, or disable scraping if it does not expose any:
spec: {
runtime: 'nodejs',
observability: {
prometheusMetrics: {
enabled: true,
path: '/metrics',
port: 'http',
},
},
},
Platform-provided mounts
If your namespace has the relevant addons enabled, Gappynator mounts the following into every pod automatically. You do not declare them in filesFrom — see Namespace Onboarding for how the underlying ConfigMaps are provisioned.
| Addon | Mounted at | Purpose |
|---|---|---|
ca-bundle | /etc/ssl/certs/ca-certificates.crt | Gjensidige's internal CA bundle, for TLS to internal services |
java-key-store | /etc/ssl/certs/java/cacerts | The Java trust store, containing the same internal CAs |
jwt-config | /mnt/jwt-config | JWT properties and signing certificate |
Some Java base images keep cacerts somewhere other than the default path. Override it with an annotation rather than a filesFrom entry:
metadata: {
annotations: {
'gap.io/cacerts-mount-path': '/opt/java/openjdk/lib/security/cacerts',
},
},
Applications using the Gjensidige Spring Boot Common Security library also need to point at the mounted JWT configuration:
gje:
security:
tomcat:
tai-plus:
jwt-config-location: "file:/mnt/jwt-config/JWTConfig.properties"
Sharing a template across applications
Once you have more than a couple of applications, the environment files start to repeat themselves. Every team has factored the shared parts into a library under templates/, leaving each app.jsonnet as a handful of overrides:
local app = import '../../../templates/application.libsonnet';
local common = import '../common.jsonnet';
local params = import 'params.json';
app {
name:: common.name,
environment:: 'test',
image:: params.container_image,
github_labels:: params.github,
secrets:: common.secrets,
}
The template then derives the Key Vault name, the host and the labels from environment. Look at an existing manifest repository for a working example before writing your own.
Next steps
- Gappynator overview — what the operator generates and why
- API reference — the full
Applicationspecification - Ephemeral Environments — a deployment per pull request
- Alerts — adding alert rules alongside your application
- Register Deployments — reporting deployments for KPI tracking