diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..79fe09d --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,36 @@ +# Builds the UI image (also used for the hydra-clients Job) for the Kubernetes deployment. +# Tag = full commit SHA: bulbashenko-com/infra pins the same SHA for the configs and the image. +name: image + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + ui: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: ui + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/auth-ui:${{ github.sha }} + ghcr.io/${{ github.repository_owner }}/auth-ui:main + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index c34e3d4..bde4197 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ Groups decide access. Each OIDC client names one group in `metadata.required_gro | `mail-users` | Mail clients and the Stalwart web UI | | `vpn-users` | Joining the VPN mesh (Headscale) | +## Deploy on Kubernetes + +`kustomization.yaml` at the root is a Kustomize base built from the same `ory/` files that `compose.yml` mounts: Deployments for Kratos, Hydra and Keto (each runs its migration as an init container), Oathkeeper and the UI, the `hydra-clients` Job, and Traefik routes (`auth-public` on the `web` entrypoint, `auth-admin` for `users.int` behind the mesh and Oathkeeper). Service names match the compose stack, so the configs' internal URLs work unchanged. + +The deploying side provides the namespace, the image tag and a Secret `auth` with `KRATOS_DSN`, `HYDRA_DSN`, `KETO_DSN`, `KRATOS_SECRETS_{COOKIE,DEFAULT,CIPHER,PAGINATION}`, `HYDRA_SECRETS_{SYSTEM,COOKIE,PAGINATION}`, `SMTP_CONNECTION_URI`, `SMTP_SENDER` and `OIDC_{SITE,COOLIFY,HEADSCALE}_CLIENT_SECRET`. bulbashenko.com deploys it from [bulbashenko-com/infra](https://github.com/bulbashenko-com/infra), pinned to one commit of this repo; `.github/workflows/image.yml` builds `ghcr.io/bulbashenko-com/auth-ui:` for that commit. + ## Deploy on Coolify 1. **Databases.** In the shared Postgres, create three databases, each with its own role: diff --git a/deploy/k8s/ory.yaml b/deploy/k8s/ory.yaml new file mode 100644 index 0000000..efd8c3e --- /dev/null +++ b/deploy/k8s/ory.yaml @@ -0,0 +1,277 @@ +# Ory services. Configs come from ConfigMaps generated from ory/ (see the root kustomization.yaml); +# DSNs and secrets from the Secret `auth`, which the deploying repo (bulbashenko-com/infra) provides. +# Service names match the compose stack, so the configs' internal URLs (kratos:4433, hydra:4445, keto:4466) still work. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kratos +spec: + replicas: 1 + selector: + matchLabels: { app: kratos } + template: + metadata: + labels: { app: kratos } + spec: + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + initContainers: + - name: migrate + image: oryd/kratos:v26.2.0 + args: ["migrate", "sql", "-e", "--yes", "-c", "/etc/config/kratos/kratos.yml"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_DSN } } } + volumeMounts: + - { name: config, mountPath: /etc/config/kratos } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + containers: + - name: kratos + image: oryd/kratos:v26.2.0 + args: ["serve", "-c", "/etc/config/kratos/kratos.yml", "--watch-courier"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: LOG_LEVEL, value: info } + - { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_DSN } } } + - { name: SECRETS_COOKIE, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_COOKIE } } } + - { name: SECRETS_DEFAULT, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_DEFAULT } } } + - { name: SECRETS_CIPHER, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_CIPHER } } } + - { name: SECRETS_PAGINATION, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_PAGINATION } } } + - { name: COURIER_SMTP_CONNECTION_URI, valueFrom: { secretKeyRef: { name: auth, key: SMTP_CONNECTION_URI } } } + - { name: COURIER_SMTP_FROM_ADDRESS, valueFrom: { secretKeyRef: { name: auth, key: SMTP_SENDER } } } + ports: + - { name: public, containerPort: 4433 } + - { name: admin, containerPort: 4434 } + readinessProbe: + httpGet: { path: /admin/health/ready, port: admin } + livenessProbe: + httpGet: { path: /admin/health/alive, port: admin } + resources: + requests: { cpu: 10m, memory: 48Mi } + limits: { memory: 256Mi } + volumeMounts: + - { name: config, mountPath: /etc/config/kratos } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumes: + - name: config + projected: + sources: + - configMap: { name: kratos-config } + - configMap: + name: kratos-courier-templates + items: + - { key: recovery_code.invalid.email.body.gotmpl, path: courier-templates/recovery_code/invalid/email.body.gotmpl } + - { key: recovery_code.invalid.email.body.plaintext.gotmpl, path: courier-templates/recovery_code/invalid/email.body.plaintext.gotmpl } + - { key: recovery_code.invalid.email.subject.gotmpl, path: courier-templates/recovery_code/invalid/email.subject.gotmpl } + - { key: recovery_code.valid.email.body.gotmpl, path: courier-templates/recovery_code/valid/email.body.gotmpl } + - { key: recovery_code.valid.email.body.plaintext.gotmpl, path: courier-templates/recovery_code/valid/email.body.plaintext.gotmpl } + - { key: recovery_code.valid.email.subject.gotmpl, path: courier-templates/recovery_code/valid/email.subject.gotmpl } + - { key: verification_code.invalid.email.body.gotmpl, path: courier-templates/verification_code/invalid/email.body.gotmpl } + - { key: verification_code.invalid.email.body.plaintext.gotmpl, path: courier-templates/verification_code/invalid/email.body.plaintext.gotmpl } + - { key: verification_code.invalid.email.subject.gotmpl, path: courier-templates/verification_code/invalid/email.subject.gotmpl } + - { key: verification_code.valid.email.body.gotmpl, path: courier-templates/verification_code/valid/email.body.gotmpl } + - { key: verification_code.valid.email.body.plaintext.gotmpl, path: courier-templates/verification_code/valid/email.body.plaintext.gotmpl } + - { key: verification_code.valid.email.subject.gotmpl, path: courier-templates/verification_code/valid/email.subject.gotmpl } +--- +apiVersion: v1 +kind: Service +metadata: + name: kratos +spec: + selector: { app: kratos } + ports: + - { name: public, port: 4433, targetPort: public } + - { name: admin, port: 4434, targetPort: admin } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hydra +spec: + replicas: 1 + selector: + matchLabels: { app: hydra } + template: + metadata: + labels: { app: hydra } + spec: + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + initContainers: + - name: migrate + image: oryd/hydra:v26.2.0 + args: ["migrate", "sql", "up", "-e", "--yes", "-c", "/etc/config/hydra/hydra.yml"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_DSN } } } + volumeMounts: + - { name: config, mountPath: /etc/config/hydra } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + containers: + - name: hydra + image: oryd/hydra:v26.2.0 + args: ["serve", "all", "-c", "/etc/config/hydra/hydra.yml"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: LOG_LEVEL, value: info } + - { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_DSN } } } + - { name: SECRETS_SYSTEM, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_SECRETS_SYSTEM } } } + - { name: SECRETS_COOKIE, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_SECRETS_COOKIE } } } + - { name: SECRETS_PAGINATION, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_SECRETS_PAGINATION } } } + ports: + - { name: public, containerPort: 4444 } + - { name: admin, containerPort: 4445 } + readinessProbe: + httpGet: { path: /health/ready, port: admin } + livenessProbe: + httpGet: { path: /health/alive, port: admin } + resources: + requests: { cpu: 10m, memory: 48Mi } + limits: { memory: 256Mi } + volumeMounts: + - { name: config, mountPath: /etc/config/hydra } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumes: + - name: config + configMap: { name: hydra-config } +--- +apiVersion: v1 +kind: Service +metadata: + name: hydra +spec: + selector: { app: hydra } + ports: + - { name: public, port: 4444, targetPort: public } + - { name: admin, port: 4445, targetPort: admin } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keto +spec: + replicas: 1 + selector: + matchLabels: { app: keto } + template: + metadata: + labels: { app: keto } + spec: + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + initContainers: + - name: migrate + image: oryd/keto:v26.2.0 + args: ["migrate", "up", "--yes", "-c", "/etc/config/keto/keto.yml"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KETO_DSN } } } + volumeMounts: + - { name: config, mountPath: /etc/config/keto } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + containers: + - name: keto + image: oryd/keto:v26.2.0 + args: ["serve", "-c", "/etc/config/keto/keto.yml"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: LOG_LEVEL, value: info } + - { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KETO_DSN } } } + ports: + - { name: read, containerPort: 4466 } + - { name: write, containerPort: 4467 } + readinessProbe: + httpGet: { path: /health/ready, port: read } + livenessProbe: + httpGet: { path: /health/alive, port: read } + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { memory: 192Mi } + volumeMounts: + - { name: config, mountPath: /etc/config/keto } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumes: + - name: config + configMap: { name: keto-config } +--- +apiVersion: v1 +kind: Service +metadata: + name: keto +spec: + selector: { app: keto } + ports: + - { name: read, port: 4466, targetPort: read } + - { name: write, port: 4467, targetPort: write } +--- +# Traefik forward-auth for admin UIs: GET http://oathkeeper..svc:4456/decisions. No database. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: oathkeeper +spec: + replicas: 1 + selector: + matchLabels: { app: oathkeeper } + template: + metadata: + labels: { app: oathkeeper } + spec: + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: oathkeeper + image: oryd/oathkeeper:v26.2.0 + args: ["serve", "-c", "/etc/config/oathkeeper/oathkeeper.yml"] + env: + - { name: SQA_OPT_OUT, value: "true" } + - { name: LOG_LEVEL, value: info } + ports: + - { name: api, containerPort: 4456 } + readinessProbe: + httpGet: { path: /health/ready, port: api } + livenessProbe: + httpGet: { path: /health/alive, port: api } + resources: + requests: { cpu: 10m, memory: 24Mi } + limits: { memory: 128Mi } + volumeMounts: + - { name: config, mountPath: /etc/config/oathkeeper } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumes: + - name: config + configMap: { name: oathkeeper-config } +--- +apiVersion: v1 +kind: Service +metadata: + name: oathkeeper +spec: + selector: { app: oathkeeper } + ports: + - { name: api, port: 4456, targetPort: api } diff --git a/deploy/k8s/routes.yaml b/deploy/k8s/routes.yaml new file mode 100644 index 0000000..2591aaf --- /dev/null +++ b/deploy/k8s/routes.yaml @@ -0,0 +1,96 @@ +# Traefik routes. Public traffic reaches Traefik's `web` entrypoint through Cloudflare Tunnel; the admin page +# (users.int) is served on `websecure` from the mesh only, behind Oathkeeper. Hosts are the production ones; +# other environments patch them. +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: auth-public +spec: + entryPoints: [web] + routes: + # Form submissions that carry secrets: rate-limited (Kratos has no lockout of its own). + - match: Host(`auth.bulbashenko.com`) && Method(`POST`) && PathRegexp(`^/self-service/(login|recovery|verification|settings)`) + priority: 200 + middlewares: [{ name: limit-ip }, { name: limit-all }, { name: headers }] + services: [{ name: kratos, port: 4433 }] + - match: Host(`auth.bulbashenko.com`) && Path(`/oauth2/token`) + priority: 200 + middlewares: [{ name: limit-ip }, { name: limit-all }, { name: headers }] + services: [{ name: hydra, port: 4444 }] + - match: Host(`auth.bulbashenko.com`) && (PathPrefix(`/self-service/`) || PathPrefix(`/sessions/`) || PathPrefix(`/.well-known/ory/`)) + priority: 100 + middlewares: [{ name: headers }] + services: [{ name: kratos, port: 4433 }] + - match: Host(`auth.bulbashenko.com`) && (PathPrefix(`/oauth2/`) || Path(`/userinfo`) || Path(`/.well-known/openid-configuration`) || Path(`/.well-known/jwks.json`) || Path(`/.well-known/webfinger`)) + priority: 100 + middlewares: [{ name: headers }] + services: [{ name: hydra, port: 4444 }] + - match: Host(`auth.bulbashenko.com`) + priority: 10 + middlewares: [{ name: headers }] + services: [{ name: sso-ui, port: 3000 }] +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: auth-admin +spec: + entryPoints: [websecure] + routes: + - match: Host(`users.int.bulbashenko.com`) + middlewares: + - { name: mesh-only, namespace: kube-system } + - { name: forward-auth } + - { name: headers } + services: [{ name: sso-ui, port: 3000 }] + tls: {} +--- +# Per client (Cloudflare's CF-Connecting-IP): 10 per minute, bursts of 5. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: limit-ip +spec: + rateLimit: + average: 10 + period: 1m + burst: 5 + sourceCriterion: + requestHeaderName: CF-Connecting-IP +--- +# Everyone together: 120 per minute, so rotating addresses do not help either. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: limit-all +spec: + rateLimit: + average: 120 + period: 1m + burst: 20 + sourceCriterion: + requestHost: true +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: headers +spec: + headers: + contentTypeNosniff: true + frameDeny: true + referrerPolicy: strict-origin-when-cross-origin + stsSeconds: 31536000 + stsIncludeSubdomains: true +--- +# For every mesh admin UI: the address uses this namespace's Oathkeeper. Other namespaces reference it as +# -forward-auth@kubernetescrd or by name+namespace in IngressRoutes. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: forward-auth +spec: + forwardAuth: + address: http://oathkeeper.auth.svc.cluster.local:4456/decisions + trustForwardHeader: true + authResponseHeaders: [Remote-User, Remote-Email, Remote-Name] diff --git a/deploy/k8s/ui.yaml b/deploy/k8s/ui.yaml new file mode 100644 index 0000000..9f67102 --- /dev/null +++ b/deploy/k8s/ui.yaml @@ -0,0 +1,93 @@ +# Login, settings, consent and the admin page (users.int). Image built by .github/workflows/image.yml. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sso-ui +spec: + replicas: 1 + selector: + matchLabels: { app: sso-ui } + template: + metadata: + labels: { app: sso-ui } + spec: + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: sso-ui + image: ghcr.io/bulbashenko-com/auth-ui:latest + env: + - { name: PUBLIC_URL, value: "https://auth.bulbashenko.com" } + - { name: ADMIN_HOST, value: "users.int.bulbashenko.com" } + - { name: KRATOS_PUBLIC_URL, value: "http://kratos:4433" } + - { name: KRATOS_ADMIN_URL, value: "http://kratos:4434" } + - { name: HYDRA_ADMIN_URL, value: "http://hydra:4445" } + - { name: KETO_READ_URL, value: "http://keto:4466" } + - { name: KETO_WRITE_URL, value: "http://keto:4467" } + ports: + - { name: http, containerPort: 3000 } + readinessProbe: + httpGet: { path: /api/health, port: http } + livenessProbe: + httpGet: { path: /api/health, port: http } + periodSeconds: 20 + resources: + requests: { cpu: 20m, memory: 96Mi } + limits: { memory: 384Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumeMounts: + - { name: cache, mountPath: /app/.next/cache } + - { name: tmp, mountPath: /tmp } + volumes: + - { name: cache, emptyDir: {} } + - { name: tmp, emptyDir: {} } +--- +apiVersion: v1 +kind: Service +metadata: + name: sso-ui +spec: + selector: { app: sso-ui } + ports: + - { name: http, port: 3000, targetPort: http } +--- +# Creates or updates the OIDC clients from ory/hydra/clients/*.json. Re-runs when the clients change +# (ConfigMap hash in the pod spec) thanks to Flux's force annotation. +apiVersion: batch/v1 +kind: Job +metadata: + name: hydra-clients + annotations: + kustomize.toolkit.fluxcd.io/force: enabled +spec: + backoffLimit: 10 + template: + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: sync + image: ghcr.io/bulbashenko-com/auth-ui:latest + command: ["node", "scripts/sync-clients.mjs", "/app/clients"] + env: + - { name: HYDRA_ADMIN_URL, value: "http://hydra:4445" } + - { name: OIDC_SITE_CLIENT_SECRET, valueFrom: { secretKeyRef: { name: auth, key: OIDC_SITE_CLIENT_SECRET } } } + - { name: OIDC_COOLIFY_CLIENT_SECRET, valueFrom: { secretKeyRef: { name: auth, key: OIDC_COOLIFY_CLIENT_SECRET } } } + - { name: OIDC_HEADSCALE_CLIENT_SECRET, valueFrom: { secretKeyRef: { name: auth, key: OIDC_HEADSCALE_CLIENT_SECRET } } } + resources: + requests: { cpu: 10m, memory: 48Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumeMounts: + - { name: clients, mountPath: /app/clients } + volumes: + - name: clients + configMap: { name: hydra-clients } diff --git a/kustomization.yaml b/kustomization.yaml new file mode 100644 index 0000000..cb215b1 --- /dev/null +++ b/kustomization.yaml @@ -0,0 +1,46 @@ +# Kubernetes base for the Ory stack, consumed by bulbashenko-com/infra (Flux GitRepository pinned to a commit). +# Configs are the same files compose.yml mounts. The deploying side supplies the Secret `auth`, the namespace +# and the auth-ui image tag (same commit as this repo). +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deploy/k8s/ory.yaml + - deploy/k8s/ui.yaml + - deploy/k8s/routes.yaml +configMapGenerator: + - name: kratos-config + files: + - ory/kratos/kratos.yml + - ory/kratos/identity.schema.json + - name: kratos-courier-templates + files: + - recovery_code.invalid.email.body.gotmpl=ory/kratos/courier-templates/recovery_code/invalid/email.body.gotmpl + - recovery_code.invalid.email.body.plaintext.gotmpl=ory/kratos/courier-templates/recovery_code/invalid/email.body.plaintext.gotmpl + - recovery_code.invalid.email.subject.gotmpl=ory/kratos/courier-templates/recovery_code/invalid/email.subject.gotmpl + - recovery_code.valid.email.body.gotmpl=ory/kratos/courier-templates/recovery_code/valid/email.body.gotmpl + - recovery_code.valid.email.body.plaintext.gotmpl=ory/kratos/courier-templates/recovery_code/valid/email.body.plaintext.gotmpl + - recovery_code.valid.email.subject.gotmpl=ory/kratos/courier-templates/recovery_code/valid/email.subject.gotmpl + - verification_code.invalid.email.body.gotmpl=ory/kratos/courier-templates/verification_code/invalid/email.body.gotmpl + - verification_code.invalid.email.body.plaintext.gotmpl=ory/kratos/courier-templates/verification_code/invalid/email.body.plaintext.gotmpl + - verification_code.invalid.email.subject.gotmpl=ory/kratos/courier-templates/verification_code/invalid/email.subject.gotmpl + - verification_code.valid.email.body.gotmpl=ory/kratos/courier-templates/verification_code/valid/email.body.gotmpl + - verification_code.valid.email.body.plaintext.gotmpl=ory/kratos/courier-templates/verification_code/valid/email.body.plaintext.gotmpl + - verification_code.valid.email.subject.gotmpl=ory/kratos/courier-templates/verification_code/valid/email.subject.gotmpl + - name: hydra-config + files: + - ory/hydra/hydra.yml + - name: hydra-clients + files: + - ory/hydra/clients/bulbashenko-site.json + - ory/hydra/clients/coolify.json + - ory/hydra/clients/headscale.json + - ory/hydra/clients/stalwart-webui.json + - ory/hydra/clients/thunderbird.json + - name: keto-config + files: + - ory/keto/keto.yml + - ory/keto/namespaces.keto.ts + - name: oathkeeper-config + files: + - ory/oathkeeper/oathkeeper.yml + - ory/oathkeeper/rules.yml diff --git a/ui/Dockerfile b/ui/Dockerfile index 21adad4..4fb032d 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -14,7 +14,7 @@ RUN npm run build FROM node:24.21.0-alpine AS runtime LABEL org.opencontainers.image.title="auth.bulbashenko.com UI" \ - org.opencontainers.image.source="https://github.com/bulbashenko/auth" \ + org.opencontainers.image.source="https://github.com/bulbashenko-com/auth" \ org.opencontainers.image.licenses="MIT" WORKDIR /app ENV NODE_ENV=production \