← Back to Blog
CI/CD Deployment Strategies: From CircleCI to Railway

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: main

Docker 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: LoadBalancer

Railway: 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:prod

In CircleCI:

- run:
    name: Run migrations
    command: npm run migrate:deploy
    environment:
      DATABASE_URL: $PRODUCTION_DATABASE_URL

Monitoring 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

  1. Always run tests before deployment: Never deploy broken code
  2. Use staging environments: Test deployments in staging first
  3. Implement health checks: Kubernetes/ECS should know when your app is ready
  4. Automate rollbacks: If deployment fails, auto-rollback
  5. Monitor deployments: Use tools like Datadog or New Relic
  6. Use feature flags: Deploy code without exposing features
  7. 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.

Share this article

Help others discover this content


Jason Cochran

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.