Deployment Strategies
Deploy your IGNIS application reliably, securely, and efficiently.
1. Building for Production
Compile the application to one executable before deploying:
bun run compileWhat this does: bun .configs/builders/compile.ts, which bundles src/index.ts with BunCompiler, registers the artifacts plugin, bakes in the build stamp and type-checks first (check: true): a type error stops the build. See Stamping a build.
Output: dist/bin, a standalone binary for bun-linux-x64. Set BUN_TARGET to build for another target, such as bun-linux-x64-musl for Alpine. bun run clean (devz clean) removes dist/.
2. Environment Configuration
Use environment variables for all configuration - never hard-code.
Production Environment Variables:
| Variable | Value | Purpose |
|---|---|---|
NODE_ENV | production | Enables performance optimizations. Error responses are fail-closed: an unset or unrecognized value is treated as production |
APP_ENV_APPLICATION_SECRET | Strong random string | Application secret |
APP_ENV_JWT_SECRET | Strong random string | JWT signing key |
APP_ENV_POSTGRES_HOST / _PORT / _USERNAME / _PASSWORD / _DATABASE | Production DB credentials | Database connection |
APP_ENV_SERVER_HOST | 0.0.0.0 | Accept connections from any IP |
APP_ENV_SERVER_PORT | 3000 or cloud-assigned | Server port (PORT is also honoured) |
APP_ENV_LOGGER_LEVEL | info | Logger floor - debug | info | warn | error | emerg |
APP_ENV_LOGGER_FOLDER_PATH | Writable path | Opt-in rotating file output; unset means console only |
WARNING
Leave APP_ENV_LOGGER_DO_REDACT unset in production. Setting it to false disables secret-key redaction in logs - a local-debugging switch only.
Where to store:
- Docker: Use environment variables in
docker-compose.ymlor secrets - Kubernetes: ConfigMaps and Secrets
- Cloud Platforms: AWS Secrets Manager, Azure Key Vault, Google Secret Manager
3. Deployment Methods
Docker Deployment (Recommended)
An app created by vulcan add --preset ignis --app <name> already has its Dockerfile, in .configs/builders/Dockerfile. Build it from the project root, and pass the commit:
docker build -f services/api/.configs/builders/Dockerfile -t api \
--build-arg APP_BUILD_COMMIT="$(git rev-parse HEAD)" \
--build-arg APP_BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" .
docker run -p 3000:3000 -e NODE_ENV=production -e APP_ENV_APPLICATION_SECRET=xxx apiThe commit is required: the root .dockerignore leaves .git out, so the build stamp cannot read it inside the image. A build without APP_BUILD_COMMIT stops with the command to run. The commit also lands in the image's org.opencontainers.image.revision label, so docker inspect shows it.
| Stage | What it does |
|---|---|
builder | oven/bun:1.4.3-alpine: copies the whole project, bun install --frozen-lockfile --ignore-scripts, then bun run compile for bun-linux-<arch>-musl |
production | alpine:3.21 with the binary, libstdc++ and libgcc, run as a non-root user on the app's port |
| Build argument | Meaning |
|---|---|
APP_BUILD_COMMIT | Required: a hex commit sha of 7 characters or more. The build stamp and the image label carry it. The literal HEAD that git rev-parse HEAD prints before a first commit is refused |
APP_BUILD_BRANCH, APP_BUILD_VERSION | Optional. Read by the build stamp like the commit |
BUN_CONFIG_REGISTRY | Optional. A private npm mirror for bun install |
WARNING
The build copies the whole project, so a .env in the context would put its secrets into the builder layer, and a CI layer cache can keep that layer. The root .dockerignore must exclude them:
**/.env
**/.env.*
!**/.env.examplevulcan init writes these lines from VULCAN 0.3.7 on, and vulcan doctor warns when they are missing. The Dockerfile also refuses a context that still holds a .env file and names it. Pass a private registry through BUN_CONFIG_REGISTRY; never copy a token file such as .npmrc into the image.
The build context is the whole project, trimmed by the root .dockerignore that vulcan init writes, because the preset cannot know which workspace packages the app imports. For better layer caching, copy only the package.json files and folders the app needs, then install.
Docker Compose (Full Stack)
For complete development and staging environments with database:
docker-compose.yml:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- APP_ENV_APPLICATION_SECRET=${APP_SECRET}
- APP_ENV_JWT_SECRET=${JWT_SECRET}
- APP_ENV_POSTGRES_HOST=db
- APP_ENV_POSTGRES_PORT=5432
- APP_ENV_POSTGRES_USERNAME=ignis
- APP_ENV_POSTGRES_PASSWORD=${DB_PASSWORD}
- APP_ENV_POSTGRES_DATABASE=ignis_db
- APP_ENV_REDIS_HOST=redis
- APP_ENV_REDIS_PORT=6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=ignis
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=ignis_db
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ignis -d ignis_db"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:Deploy with docker-compose:
# Create .env file with secrets
echo "APP_SECRET=$(openssl rand -base64 32)" > .env
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f app
# Scale app (for load testing)
docker-compose up -d --scale app=3Bun Single Executable (Without Docker)
bun run compile already produced a single standalone binary at dist/bin. Build for another platform with BUN_TARGET:
BUN_TARGET=bun-linux-arm64 bun run compilePros: No dependencies needed on server, simple deployment Cons: Platform-specific, newer technology (test thoroughly)
IMPORTANT
A compiled binary MUST register a logger provider explicitly. Logger providers are sub-path exports and winston is an optional peer, so the default provider is reached through a runtime require that the bundler cannot see. Without this line the binary throws on its first log call.
// before the application is built
import { LoggerFactory } from '@venizia/ignis-helpers';
import { WinstonLogger } from '@venizia/ignis-helpers/winston';
// or: import { PinoLogger } from '@venizia/ignis-helpers/pino';
LoggerFactory.use({ provider: WinstonLogger });The same rule applies to any optional peer: only a value import of the class carries it into the bundle. Naming a driver class in @datasource({ driver: NodePostgresDriver }) is what pulls pg in.
A peer a helper loads by name - ioredis for the Redis helpers, bullmq, socket.io-client, a mail transport's library - is not in the binary either, and the first client throws [ModuleUtility.loadSync] ioredis is required. Import it statically and pass it as the helper's module option, or register it once at the entrypoint. See Compiled binaries.
Deploy:
# Copy executable and .env to server
scp dist/bin .env user@server:/app/
# Run
./bin4. Production Best Practices
Use a process manager:
- systemd - Linux service management (recommended for Bun)
- Docker/Kubernetes - Built-in orchestration
- PM2 - Alternative option
Example with systemd (Recommended for Bun):
# /etc/systemd/system/my-app.service
[Unit]
Description=My IGNIS Application
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/app
ExecStart=/app/bin
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target# Enable and start the service
sudo systemctl enable my-app
sudo systemctl start my-app
sudo systemctl status my-appExample with PM2 (Alternative):
pm2 start ./bin --name my-app
pm2 save
pm2 startupHealth checks: Add health check endpoint for load balancers:
// In application.ts
this.component(HealthCheckComponent);Access at /health (default path, configurable) for liveness/readiness probes.
5. Build Debugging
When builds fail or behave unexpectedly, use TypeScript's extended diagnostics:
# Show detailed build timing and statistics
tsc -p tsconfig.json --extendedDiagnosticsOutput includes:
- File I/O time
- Parse time
- Program time
- Bind time
- Check time
- Emit time
- Total time
Example output:
Files: 245
Lines: 89432
Nodes: 412856
Identifiers: 156234
Symbols: 98765
Types: 34567
Instantiations: 123456
Memory used: 245678K
I/O Read time: 0.23s
Parse time: 1.45s
Program time: 2.34s
Bind time: 0.56s
Check time: 4.23s
Emit time: 0.89s
Total time: 8.47sCommon issues:
- High "Check time" → Type errors or complex type inference
- High "I/O Read time" → Too many files, check
include/excludepatterns - High "Instantiations" → Complex generic types, consider simplifying
6. Dependency Management
Force Update Strategy
Keep dependencies in sync with the NPM registry using the shared scripts/force-update.sh, exposed as the force-update script in every package:
# Update to latest stable versions (from a package directory)
bun run force-update latest
# Update to pre-release versions (for testing new features)
bun run force-update nextWhat it does:
- Queries NPM registry for the specified tag (
latest,next, orhighest) - Rewrites the package's
@venizia/*dependencies inpackage.jsonto a caret range on the resolved version (^1.2.3), not an exact pin
When to use:
| Tag | Use Case |
|---|---|
latest | Production deployments, stable releases |
next | Testing new features, pre-release validation |
highest | Highest released version by semver sort |
Makefile shortcuts (repo root):
make update # Reinstall deps (postinstall runs force-update)
make update-core # Update only @venizia/ignis
make update-helpers # Update only @venizia/ignis-helpers7. Test Environment Setup
Configure separate environment for testing:
Create .env.test
# .env.test - Test environment variables
NODE_ENV=test
APP_ENV_APPLICATION_SECRET=test-secret-key
APP_ENV_JWT_SECRET=test-jwt-secret
APP_ENV_POSTGRES_HOST=localhost
APP_ENV_POSTGRES_PORT=5433
APP_ENV_POSTGRES_USERNAME=test_user
APP_ENV_POSTGRES_PASSWORD=test_password
APP_ENV_POSTGRES_DATABASE=test_dbRunning Tests
# Run all tests with test environment
NODE_ENV=test bun test --env-file=.env.test
# Run tests in watch mode
NODE_ENV=test bun test --watch --env-file=.env.test
# Run specific test file
NODE_ENV=test bun test --env-file=.env.test src/__tests__/user.test.tsTest Database Setup
For integration tests, use a separate database:
# Docker: Start test database
docker run -d \
--name ignis-test-db \
-e POSTGRES_USER=test_user \
-e POSTGRES_PASSWORD=test_password \
-e POSTGRES_DB=test_db \
-p 5433:5432 \
postgres:16
# Run migrations on test database
NODE_ENV=test bun run migrate --env-file=.env.test8. CI/CD Security
Dependency Auditing
Integrate security audits into your CI/CD pipeline:
# GitHub Actions example
name: Security Audit
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 1' # Weekly on Monday
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run security audit
run: bun audit
- name: Check for vulnerabilities
run: |
if bun audit --json | jq '.vulnerabilities | length' | grep -v '^0$'; then
echo "Vulnerabilities found!"
exit 1
fiPre-deployment Checklist
# GitHub Actions - Full CI pipeline
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install
run: bun install
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Compile
run: bun run compile
- name: Test
run: bun test --env-file=.env.test
- name: Security Audit
run: bun audit
- name: Build Docker Image
run: docker build -t my-app:${{ github.sha }} .Critical Packages to Monitor
| Package | Why | Check |
|---|---|---|
hono | Web framework, HTTP handling | Security advisories |
drizzle-orm | Database queries, SQL injection | Version updates |
jose | JWT handling, crypto | Vulnerability patches |
@venizia/ignis | Framework core | Latest stable release |
9. Cloud Platform Deployments
Railway
Railway provides simple deployments with automatic builds:
railway.json:
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"startCommand": "./bin",
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}# Deploy to Railway
railway login
railway init
railway upFly.io
fly.toml:
app = "my-ignis-app"
primary_region = "sjc"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1
[[services]]
protocol = "tcp"
internal_port = 3000
[[services.ports]]
port = 80
handlers = ["http"]
[[services.ports]]
port = 443
handlers = ["tls", "http"]
[[services.http_checks]]
interval = 10000
grace_period = "5s"
method = "get"
path = "/health"
protocol = "http"
timeout = 2000# Deploy to Fly.io
fly auth login
fly launch
fly deploy
fly secrets set APP_ENV_APPLICATION_SECRET=xxxRender
render.yaml:
services:
- type: web
name: my-ignis-app
runtime: docker
dockerfilePath: ./Dockerfile
envVars:
- key: NODE_ENV
value: production
- key: APP_ENV_APPLICATION_SECRET
generateValue: true
- key: APP_ENV_JWT_SECRET
generateValue: true
healthCheckPath: /health
autoDeploy: true
databases:
- name: ignis-db
databaseName: ignis
user: ignis
plan: starterKubernetes
k8s/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ignis-app
labels:
app: ignis
spec:
replicas: 3
selector:
matchLabels:
app: ignis
template:
metadata:
labels:
app: ignis
spec:
containers:
- name: ignis
image: my-ignis-app:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: APP_ENV_APPLICATION_SECRET
valueFrom:
secretKeyRef:
name: ignis-secrets
key: app-secret
- name: APP_ENV_JWT_SECRET
valueFrom:
secretKeyRef:
name: ignis-secrets
key: jwt-secret
- name: APP_ENV_POSTGRES_HOST
valueFrom:
configMapKeyRef:
name: ignis-config
key: db-host
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: ignis-service
spec:
selector:
app: ignis
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancerDeploy to Kubernetes:
# Create secrets
kubectl create secret generic ignis-secrets \
--from-literal=app-secret=$(openssl rand -base64 32) \
--from-literal=jwt-secret=$(openssl rand -base64 32)
# Create configmap
kubectl create configmap ignis-config \
--from-literal=db-host=postgres-service
# Apply deployment
kubectl apply -f k8s/
# Check status
kubectl get pods -l app=ignis
kubectl logs -l app=ignis -f10. Monitoring & Observability
Health Check Endpoints
IGNIS provides built-in health checks:
// In application.ts
import { HealthCheckComponent } from '@venizia/ignis';
this.component(HealthCheckComponent);Endpoints (default path /health, configurable via component options):
GET /health- Basic liveness checkPOST /health/ping- Echo/ping check (send{ "message": "..." }, get a PONG back)
Logging in Production
Configure structured logging:
import type { ILogger } from '@venizia/ignis-helpers';
import { ApplicationLogger, LoggerFactory } from '@venizia/ignis-helpers';
import { PinoLogger } from '@venizia/ignis-helpers/pino';
// One line at the entrypoint selects the provider. Omit it and winston is loaded on
// first use - which only works when winston is installed and the app is not compiled.
LoggerFactory.use({ provider: PinoLogger });
const logger: ILogger = ApplicationLogger.get('MyService');
logger.info('Service started | port: %d | env: %s', 3000, 'production');
// Method-scoped child logger
logger.for('start').warn('Slow boot | took: %d ms', 1420);Operational notes:
- Five levels, each a direct method:
debug,info,warn,error,emerg debug()is compiled out at module load unlessDEBUGis truthy andNODE_ENVis a development env - restart to change it- Log an
Errorwith%s;%jswallows the stack - Secret-looking keys redact to
[REDACTED]automatically - File output is opt-in: without
APP_ENV_LOGGER_FOLDER_PATHnothing is written to disk
Metrics Collection
Add Prometheus metrics endpoint:
// src/controllers/metrics.controller.ts
@controller({ path: '/metrics' })
export class MetricsController extends BaseRestController {
@get({ configs: { path: '/', responses: { 200: { description: 'Prometheus metrics' } } } })
getMetrics(context: Context) {
return context.text(`
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} ${requestCount}
# HELP process_uptime_seconds Process uptime
# TYPE process_uptime_seconds gauge
process_uptime_seconds ${process.uptime()}
`);
}
}Summary
| Deployment Method | Best For | Complexity |
|---|---|---|
| Docker | General production | Low |
| Docker Compose | Full stack with DB | Low |
| Bun Executable | Simple servers | Very Low |
| Railway/Render | Quick deployments | Very Low |
| Fly.io | Edge deployments | Low |
| Kubernetes | Enterprise scale | High |
Choose based on your scale and operational requirements.