Skip to content

Deploying

This guide covers packaging and deploying a Crate API to production. It assumes you have a working project with environment variable substitution set up in your config files — see the Configuration section in the Build a Complete API tutorial.

Crate projects compile to a single native binary. Build a release version:

Terminal window
dub build -b release

The output binary has no runtime dependencies beyond the system’s C library and the D shared runtime. Copy it to your server along with the config/ directory and you’re done.

Create a deploy/Dockerfile. The binary is compiled on the host (or in CI) and copied into the image:

FROM fedora:42
LABEL maintainer="you@example.com"
ARG APP_NAME=blog-api
COPY service/$APP_NAME /app/app-bin
COPY config/configuration.docker.json /app/config/configuration.json
COPY config/db.model/mongo.json /app/config/db/mongo.json
RUN mkdir -p /app/config/db && \
mkdir -p /app/files && \
mkdir -p /app/logs && \
dnf install -y openssl ImageMagick librsvg2-tools
# Service
ENV serviceName blog-api # Display name used in logs and stats
ENV serviceUrl http://localhost # Public URL of the service (used for links, redirects)
ENV apiUrl http://localhost # Public base URL for API endpoints
ENV hostName localhost # Hostname the HTTP server binds to
ENV logLevel 4 # vibe.d log verbosity (0=all, 4=warn, 6=critical)
# Mongo
ENV mongoUrl "" # Full MongoDB connection string (mongodb://user:pass@host:port/db)
ENV mongoHost localhost # MongoDB hostname, used by db-service DNS in Kubernetes
ENV mongoDBName blog # MongoDB database name, drives data isolation per tenant
# Data volumes
VOLUME ["/app/config", "/app/files", "/app/logs"]
WORKDIR /app
EXPOSE 80
ENTRYPOINT ["/app/app-bin"]

The image installs ImageMagick and librsvg for image resizing (used by CrateImageCollectionResource). If your API doesn’t handle image uploads, you can drop those packages.

Three volumes are declared: config for runtime configuration overrides, files for filesystem-based file storage, and logs for application logs.

Build and run:

Terminal window
mkdir -p deploy/service
cp blog-api deploy/service/
cd deploy
docker build -t blog-api --build-arg APP_NAME=blog-api .
docker run -e mongoUrl=mongodb://mongo:27017/blog -p 80:80 blog-api

Override env vars per environment:

Terminal window
docker run -e mongoUrl=mongodb://prod-db:27017/blog \
-e apiUrl=https://api.example.com \
-e mongoDBName=blog-prod \
-p 80:80 blog-api

For local development with MongoDB:

services:
api:
build:
context: ./deploy
args:
APP_NAME: blog-api
ports:
- "80:80"
environment:
mongoUrl: mongodb://mongo:27017/blog
mongoDBName: blog
mongoHost: mongo
apiUrl: http://localhost
depends_on:
- mongo
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:

Start everything:

Terminal window
docker compose up

MongoDB data persists in the mongo-data volume across restarts. The API connects using the mongo service name as the hostname — Docker’s internal DNS resolves it automatically.

If you use GridFS for file storage, no extra volume is needed — files are stored in MongoDB alongside your data.

helm/blog-api/
Chart.yaml
values.yaml
templates/
_helpers.tpl
api.yaml
ingress.yaml
certificate.yaml
apiVersion: v1
name: blog-api
version: 0.1.0

Define name helpers so service discovery works through Helm-computed names:

{{- define "blog-api.fullname" -}}
{{- printf "%s-%s" .Release.Name "api" | trunc 63 | trimSuffix "-" -}}
{{- end -}}

Keep it flat. Non-sensitive values go here, secrets are passed at install time via --set:

domainName: "api.example.com"
serviceName: "blog-api"
mongoDBName: "blog"
mongoUrl: ""
logLevel: 4
api:
image:
repository: registry.example.com/blog-api
tag: latest
pullPolicy: IfNotPresent
replicas: 2
environmentVars:
- envName: "mongoHost"
envValue: "db-service"

A Deployment and Service in the same file — the standard Crate deployment unit:

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "blog-api.fullname" . }}
labels:
app: {{ include "blog-api.fullname" . }}
spec:
replicas: {{ .Values.api.replicas }}
selector:
matchLabels:
app: {{ include "blog-api.fullname" . }}
template:
metadata:
labels:
app: {{ include "blog-api.fullname" . }}
spec:
containers:
- name: api
image: {{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- containerPort: 80
env:
{{- range .Values.api.environmentVars }}
- name: {{ .envName }}
value: "{{ .envValue }}"
{{- end }}
- name: serviceName
value: {{ .Values.serviceName }}
- name: apiUrl
value: https://{{ .Values.domainName }}
- name: hostName
value: {{ .Values.domainName }}
- name: mongoDBName
value: {{ .Values.mongoDBName }}
- name: mongoUrl
value: {{ .Values.mongoUrl }}
- name: logLevel
value: "{{ .Values.logLevel }}"
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "blog-api.fullname" . }}-service
spec:
selector:
app: {{ include "blog-api.fullname" . }}
ports:
- protocol: TCP
port: 80
targetPort: 80
name: http

Environment variables are split into two groups: the environmentVars list for per-deployment overrides (like mongoHost), and the static block derived from top-level values. This keeps values.yaml clean while allowing flexibility.

Route external traffic to the API through nginx ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "blog-api.fullname" . }}-ingress
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/rewrite-target: /$1
nginx.ingress.kubernetes.io/proxy-body-size: 20m
spec:
tls:
- hosts:
- {{ .Values.domainName }}
secretName: {{ include "blog-api.fullname" . }}-cert
rules:
- host: {{ .Values.domainName }}
http:
paths:
- path: /(.*)
pathType: ImplementationSpecific
backend:
service:
name: {{ include "blog-api.fullname" . }}-service
port:
number: 80

The proxy-body-size annotation is important if your API handles file uploads — nginx’s default limit is 1MB.

Automate TLS with cert-manager:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "blog-api.fullname" . }}-cert
spec:
secretName: {{ include "blog-api.fullname" . }}-cert
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- {{ .Values.domainName }}

This requires a ClusterIssuer named letsencrypt-prod to be configured in your cluster.

If MongoDB runs outside the cluster (a dedicated VM, managed service, etc.), create a headless Service with static Endpoints so pods can reach it by DNS name:

# db-service.yaml (apply once, not part of the Helm chart)
kind: Service
apiVersion: v1
metadata:
name: db-service
spec:
clusterIP: None
ports:
- port: 27017
---
kind: Endpoints
apiVersion: v1
metadata:
name: db-service
subsets:
- addresses:
- ip: 10.0.1.50
ports:
- port: 27017
name: db-service

Then set mongoHost: db-service in your environmentVars and include the same hostname in mongoUrl. Pods resolve db-service through cluster DNS to your external IP.

Terminal window
helm install --namespace blog blog ./helm/blog-api \
--set mongoDBName=blog \
--set domainName="api.example.com" \
--set mongoUrl="mongodb://user:pass@db-service:27017/blog"

The three values you always override at install time are mongoDBName, domainName, and mongoUrl. Everything else uses chart defaults.

Create a values file per environment:

values.staging.yaml
api:
replicas: 1
domainName: "staging-api.example.com"
logLevel: 6
Terminal window
helm install blog ./helm/blog-api -f values.staging.yaml \
--set mongoUrl="mongodb://user:pass@db-service:27017/blog-staging"

The same chart can deploy multiple independent instances by varying mongoDBName and domainName. Each tenant gets its own database and hostname:

Terminal window
helm install tenant-a ./helm/blog-api \
--set mongoDBName=tenant-a \
--set domainName="a.example.com" \
--set mongoUrl="mongodb://db-service:27017/tenant-a"
helm install tenant-b ./helm/blog-api \
--set mongoDBName=tenant-b \
--set domainName="b.example.com" \
--set mongoUrl="mongodb://db-service:27017/tenant-b"

The HTTPConfig struct supports an optional baseUrl field that prepends a path prefix to all routes. This is useful when running behind a reverse proxy that strips or rewrites path prefixes:

{
"http": {
"hostName": "0.0.0.0",
"baseUrl": "/api/v1"
}
}

With this configuration, all endpoints are prefixed:

Without baseUrlWith baseUrl /api/v1
POST /mcpPOST /api/v1/mcp
GET /usersGET /api/v1/users
GET /.well-known/oauth-protected-resourceGET /.well-known/oauth-protected-resource (unchanged)

The baseUrl field is optional and defaults to an empty string (no prefix). Well-known endpoints are not affected by the prefix — they remain at their standard paths.

This combines with per-policy PolicyConfig base URLs. For example, if HTTPConfig.baseUrl is /api and you use McpPolicy!(PolicyConfig("/v1")), the MCP endpoint will be at /api/v1/mcp.

The same $ and # substitution syntax in your config JSON files works identically across all deployment methods. The only thing that changes is how the environment variables are injected:

MethodHow env vars are set
Local devShell exports or .env file
DockerENV in Dockerfile, -e flag on docker run
Composeenvironment: block in docker-compose.yml
Kubernetesenv: in pod spec, from values and --set overrides
systemdEnvironment= in service unit file

The config JSON files stay the same across all environments. Only the values change.