Deployment automation has come a long way. From manual FTP uploads in the early 2000s to sophisticated CI/CD pipelines today, I've experienced the full evolution. Here's what works in 2024.
The Evolution of My Deployment Practices
Early 2000s: Manual FTP uploads, crossing fingers 2010s: Capistrano, then Jenkins Recent years: CircleCI, Docker, Kubernetes, Railway, Vercel
Each evolution brought new capabilities and reduced deployment anxiety.
CircleCI: The Workhorse
CircleCI has been my primary CI/CD platform for the past several years. Here's a production-ready configuration from the Verizon NFT project:
# .circleci/config.yml
version: 2.1
orbs:
node: circleci/node@5.0.2
docker: circleci/docker@2.1.4
aws-ecr: circleci/aws-ecr@8.1.2
aws-ecs: circleci/aws-ecs@3.2.0
jobs:
test:
docker:
- image: cimg/node:16.20
- image: cimg/postgres:13.8
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_DB: test_db
- image: cimg/redis:7.0
steps:
- checkout
- restore_cache:
keys:
- v1-dependencies-{{ checksum "package-lock.json" }}
- v1-dependencies-
- run:
name: Install dependencies
command: npm ci
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{ checksum "package-lock.json" }}
- run:
name: Wait for PostgreSQL
command: |
dockerize -wait tcp://localhost:5432 -timeout 1m
- run:
name: Run database migrations
command: npm run migrate:test
environment:
DATABASE_URL: postgresql://test_user:test_pass@localhost:5432/test_db
- run:
name: Run linter
command: npm run lint
- run:
name: Run unit tests
command: npm test -- --coverage --maxWorkers=2
- run:
name: Run integration tests
command: npm run test:integration
environment:
DATABASE_URL: postgresql://test_user:test_pass@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
- store_test_results:
path: test-results
- store_artifacts:
path: coverage
destination: coverage
build-and-push:
docker:
- image: cimg/node:16.20
steps:
- checkout
- setup_remote_docker:
docker_layer_caching: true
- run:
name: Build application
command: npm run build
- aws-ecr/build-and-push-image:
repo: nft-collectibles-api
tag: ${CIRCLE_SHA1},latest
dockerfile: Dockerfile
path: .
deploy-staging:
docker:
- image: cimg/base:stable
steps:
- aws-ecs/update-service:
cluster: nft-staging-cluster
service-name: nft-api-service
container-image-name-updates: >
container=nft-api,
tag=${CIRCLE_SHA1}
force-new-deployment: true
deploy-production:
docker:
- image: cimg/base:stable
steps:
- aws-ecs/update-service:
cluster: nft-production-cluster
service-name: nft-api-service
container-image-name-updates: >
container=nft-api,
tag=${CIRCLE_SHA1}
force-new-deployment: true
workflows:
version: 2
test-build-deploy:
jobs:
- test
- build-and-push:
requires:
- test
filters:
branches:
only:
- develop
- main
- deploy-staging:
requires:
- build-and-push
filters:
branches:
only: develop
- hold-for-approval:
type: approval
requires:
- build-and-push
filters:
branches:
only: main
- deploy-production:
requires:
- hold-for-approval
filters:
branches:
only: mainDocker Multi-Stage Builds
Efficient Docker images are crucial for fast deployments:
# Dockerfile
# Stage 1: Dependencies
FROM node:16-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:16-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production
FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
# Copy necessary files
COPY --from=deps --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
USER nestjs
EXPOSE 3000
CMD ["node", "dist/main"]Kubernetes Deployment
For the Verizon project, we deployed to AWS ECS with Kubernetes:
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nft-api
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: nft-api
template:
metadata:
labels:
app: nft-api
spec:
containers:
- name: nft-api
image: ${ECR_REGISTRY}/nft-collectibles-api:${IMAGE_TAG}
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: nft-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: nft-secrets
key: redis-url
- name: ETHEREUM_RPC_URL
valueFrom:
secretKeyRef:
name: nft-secrets
key: ethereum-rpc-url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: nft-api-service
namespace: production
spec:
selector:
app: nft-api
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancerRailway: Modern Deployment Simplified
For the Servant project, we used Railway, which simplified deployment significantly:
# railway.toml
[build]
builder = "NIXPACKS"
buildCommand = "npm run build"
[deploy]
startCommand = "npm run start:prod"
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
[env]
NODE_ENV = "production"Railway configuration in the dashboard:
- Automatic deployments from GitHub
- Environment variables managed in UI
- Built-in PostgreSQL service
- Automatic HTTPS
Vercel for Next.js
For Next.js frontends, Vercel is unbeatable:
// vercel.json
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"env": {
"NEXT_PUBLIC_API_URL": "@api-url-production"
},
"build": {
"env": {
"NEXT_PUBLIC_API_URL": "@api-url-production"
}
},
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
]
}Environment-Specific Configurations
Managing environment variables across environments:
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
url: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === 'true',
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
},
aws: {
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3Bucket: process.env.S3_BUCKET_NAME,
},
redis: {
url: process.env.REDIS_URL,
},
ethereum: {
rpcUrl: process.env.ETHEREUM_RPC_URL,
contractAddress: process.env.NFT_CONTRACT_ADDRESS,
privateKey: process.env.MINTING_PRIVATE_KEY,
},
});Database Migration Strategy
Always run migrations as part of deployment:
#!/bin/bash
# scripts/deploy.sh
set -e
echo "Running database migrations..."
npm run migrate:deploy
echo "Starting application..."
npm run start:prodIn CircleCI:
- run:
name: Run migrations
command: npm run migrate:deploy
environment:
DATABASE_URL: $PRODUCTION_DATABASE_URLMonitoring and Rollback
Always have a rollback strategy:
# scripts/rollback.sh
#!/bin/bash
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^)
echo "Rolling back to $PREVIOUS_TAG"
# Update ECS service to previous version
aws ecs update-service \
--cluster production-cluster \
--service api-service \
--task-definition api:$PREVIOUS_TAG \
--force-new-deployment
echo "Rollback initiated"Best Practices
- Always run tests before deployment: Never deploy broken code
- Use staging environments: Test deployments in staging first
- Implement health checks: Kubernetes/ECS should know when your app is ready
- Automate rollbacks: If deployment fails, auto-rollback
- Monitor deployments: Use tools like Datadog or New Relic
- Use feature flags: Deploy code without exposing features
- Keep secrets secret: Never commit credentials
Conclusion
Modern CI/CD has made deployment incredibly reliable. The key is choosing the right tools for your needs:
- CircleCI: Great for complex workflows and custom requirements
- Railway: Perfect for rapid deployment of full-stack apps
- Vercel: Unbeatable for Next.js applications
- Docker + Kubernetes: When you need full control and scale
The best deployment strategy is one that gives you confidence to deploy frequently and safely.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.