While building a modern invoicing application, we ran into a classic problem: painfully slow deployments. Our stack—Meteor 3.3, SolidJS, Vite, and ARM64 architecture—is powerful and modern, but traditional CI/CD approaches were taking 20+ minutes per deployment. When you’re iterating on features like real-time invoice generation, client management, and payment integrations, waiting that long for each deployment kills productivity.

This post explores how we solved this problem with a two-tier deployment strategy that reduced deployment times from 20 minutes to under a minute for code changes.

About the Project

Our invoicing application is designed to solve a real problem: making it effortless for freelancers and small businesses to create, send, and get paid for their work. As a completely free service at invoicing.productive.me, we focus on removing friction from the invoicing process—no complex setup, no monthly fees, just simple invoice generation that works.

This focus on speed and simplicity drove our technology choices. We needed a stack that could deliver:

  • Meteor 3.3 as the full-stack framework with integrated build system, offering authentication and real-time data
  • SolidJS for reactive UI that updates instantly as users type
  • Vite for lightning-fast development and optimized production builds
  • MongoDB for flexible document storage (invoices, clients, line items)
  • TailwindCSS for utility-first styling that keeps the UI clean
  • ARM64 deployment targeting modern cloud infrastructure for cost efficiency

Since we’re building a free service, keeping infrastructure costs low while maintaining fast performance is crucial. But with multiple features shipping daily—new payment integrations, UI improvements, mobile optimizations—our deployment pipeline became a significant bottleneck. Every small bug fix or UX tweak required a full 20-minute rebuild, slowing down our ability to iterate quickly for our users.

The Challenge

Modern web applications have complex build requirements:

  • Meteor 3.3 with modern JavaScript features
  • SolidJS for reactive UI components
  • Vite for fast development and optimized builds
  • ARM64 architecture for Apple Silicon and modern cloud instances
  • Multi-stage Docker builds for production optimization

The traditional approach of rebuilding everything for each deployment becomes a productivity killer when you’re iterating quickly.

Our Solution: Two-Tier Deployment Strategy

We implemented a two-tier approach:

  1. Full Base Image: Built via GitHub Actions when dependencies change (~20 minutes)
  2. Light Overlay: Built locally for code changes (~4 minutes, 5x faster!)

Base Image: productiveme/meteor-base

First, we created a custom ARM64 base image supporting Meteor v3.2+. This image includes all the heavy dependencies and build tools needed for Meteor applications. The productiveme/meteor-base project provides optimized Docker images specifically for modern Meteor applications, forked from Geoffrey Booth’s excellent work in disney/meteor-base.

Our multi-stage Dockerfile leverages this base:

# Multistage Dockerfile using productiveme/meteor-base
# The tag here should match the Meteor version of your app, per .meteor/release
FROM productiveme/meteor-base:3.3 AS builder

# Set environment for build
ENV METEOR_ALLOW_SUPERUSER=true

# Copy app package.json and package-lock.json into container
COPY package*.json $APP_SOURCE_FOLDER/

RUN bash $SCRIPTS_FOLDER/build-app-npm-dependencies.sh

# Copy app source into container
COPY . $APP_SOURCE_FOLDER/

# Fix ownership and permissions
RUN chown -R root:root $APP_SOURCE_FOLDER && \
    find $APP_SOURCE_FOLDER/.meteor/local -type d -exec chmod 755 {} \; 2>/dev/null || true && \
    find $APP_SOURCE_FOLDER/.meteor/local -type f -exec chmod 644 {} \; 2>/dev/null || true

# OPTIONAL: additional files for runtime like database migration scripts
RUN mkdir -p /tmp/migrations-bundle && \
    if [ -f $APP_SOURCE_FOLDER/migrate-mongo-config.js ]; then cp $APP_SOURCE_FOLDER/migrate-mongo-config.js /tmp/migrations-bundle/; fi && \
    if [ -d $APP_SOURCE_FOLDER/migrations ]; then cp -r $APP_SOURCE_FOLDER/migrations /tmp/migrations-bundle/; fi

# Build the Meteor bundle
RUN bash $SCRIPTS_FOLDER/build-meteor-bundle.sh

# Production runtime stage
# Use the specific version of Node expected by your Meteor release, per https://docs.meteor.com/changelog.html
# This is expected for Meteor 3.3
FROM node:22.16.0-alpine AS production

# Set environment variables to match meteor-base
ENV APP_SOURCE_FOLDER=/opt/src
ENV APP_BUNDLE_FOLDER=/opt/bundle
ENV SCRIPTS_FOLDER=/docker

# Runtime dependencies; includes native compilation tools for any native modules
RUN apk --no-cache add \
    bash \
    ca-certificates \
    python3 \
    make \
    g++ \
    libstdc++ \
    gcc

# Copy entrypoint scripts from builder stage
COPY --from=builder $SCRIPTS_FOLDER $SCRIPTS_FOLDER/

# Copy source files needed at runtime (migrations, config)
RUN mkdir -p $APP_SOURCE_FOLDER
COPY --from=builder /tmp/migrations-bundle/ $APP_SOURCE_FOLDER/

# Copy the built app bundle from builder stage
COPY --from=builder $APP_BUNDLE_FOLDER/bundle $APP_BUNDLE_FOLDER/bundle/

# Install server dependencies with native compilation
RUN bash $SCRIPTS_FOLDER/build-meteor-npm-dependencies.sh

# Copy your app's package.json into the bundle root for vite compatibility
COPY --from=builder $APP_SOURCE_FOLDER/package*.json $APP_BUNDLE_FOLDER/bundle/

# Install root-level deps so meteor+vite is available to Vite bootstrap
WORKDIR $APP_BUNDLE_FOLDER/bundle
RUN npm install --only=production --no-audit --no-fund

# Create app user for security
RUN addgroup -g 1001 meteor && \
    adduser -D -u 1001 -G meteor meteor

# Change ownership of app files to meteor user
RUN chown -R meteor:meteor $APP_BUNDLE_FOLDER && \
    if [ -d $APP_SOURCE_FOLDER ]; then chown -R meteor:meteor $APP_SOURCE_FOLDER; fi

# Switch to non-root user
USER meteor

# Set working directory back to root for entrypoint
WORKDIR /

# Expose port
EXPOSE 3000

# Environment variables with defaults
ENV NODE_ENV=production
ENV PORT=3000
ENV ROOT_URL=http://localhost:3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD curl --head --fail --silent --show-error http://localhost:$PORT || exit 1

# Start app using meteor-base entrypoint
ENTRYPOINT ["/docker/entrypoint.sh"]
CMD ["node", "main.js"]

This creates a ~1.5GB production image with all dependencies installed and optimized.

GitHub Actions: Full CI Pipeline

Our GitHub Actions workflow builds the complete base image when dependencies change:

name: Build Full Production Image

on:
  workflow_dispatch:
    inputs:
      deploy_to_caprover:
        description: 'Deploy to CapRover after build'
        required: false
        default: true
        type: boolean

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set image names
        run: |
          echo "IMAGE_URL=$(echo ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
          echo "IMAGE_URL_LATEST=$(echo ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ${{ env.IMAGE_URL }}
            ${{ env.IMAGE_URL_LATEST }}
          platforms: linux/arm64/v8
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # Enable BuildKit cache mounts for npm/dependency caching
          build-args: |
            BUILDKIT_INLINE_CACHE=1
            NODE_VERSION=22.16.0

      - name: Deploy to CapRover
        if: ${{ github.event.inputs.deploy_to_caprover == 'true' }}
        uses: caprover/deploy-from-[email protected]
        with:
          server: ${{ secrets.CAPROVER_SERVER_URL }}
          app: ${{ secrets.CAPROVER_APP_NAME }}
          token: ${{ secrets.CAPROVER_APP_TOKEN }}
          image: ${{ env.IMAGE_URL_LATEST }}

Key features of this pipeline:

  • ARM64 native builds using platforms: linux/arm64/v8
  • GitHub Actions cache for faster subsequent builds
  • Automatic CapRover deployment after successful builds
  • Manual workflow dispatch so you control when full rebuilds happen

The Game Changer: quickdeploy.sh

Here’s where the magic happens. Our quickdeploy.sh script creates a light overlay that replaces only the source code files in the existing production image:

#!/bin/bash

set -e  # Exit on any error

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Configuration
DEPLOY_DIR=".deploy"
BASE_IMAGE="ghcr.io/productiveme/invoicing:latest"

print_status() {
    echo -e "${GREEN}[INFO]${NC} $1"
}

print_status "🚀 Starting LIGHT deployment using base image..."
print_status "📦 Base image: ${BASE_IMAGE}"

# Check if base image exists
if ! docker manifest inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
    print_error "Base production image not found: ${BASE_IMAGE}"
    print_error "Build it first using: GitHub Actions → 'Build Full Production Image' workflow"
    exit 1
fi

# Clean up previous builds
if [ -d "${DEPLOY_DIR}" ]; then
    print_status "Cleaning up previous deployment files..."
    rm -rf "${DEPLOY_DIR}"
fi

# Create deploy directory
mkdir -p "${DEPLOY_DIR}"

print_status "⚡ Building Meteor application locally (FAST - ~40s)..."

# Build the Meteor application directly into deploy directory
NODE_ENV=production meteor build "${DEPLOY_DIR}" --directory

# The bundle should now be at .deploy/bundle
BUNDLE_PATH="${DEPLOY_DIR}/bundle"
if [ ! -d "${BUNDLE_PATH}" ]; then
    print_error "Meteor build failed - bundle directory not found at ${BUNDLE_PATH}"
    exit 1
fi

print_status "🔍 Identifying files that changed from base image..."

# Create light overlay Dockerfile
cat > "${DEPLOY_DIR}/Dockerfile" << EOF
# Light deployment - replaces only source code files in existing production image
FROM ${BASE_IMAGE}

# Switch to root to perform light update, then back to meteor user
USER root

# Remove old source code files (light removal)
RUN rm -rf /opt/bundle/bundle/programs/server/app/ \\
    && rm -rf /opt/bundle/bundle/programs/server/packages/ \\
    && rm -rf /opt/bundle/bundle/programs/web.browser/ \\
    && rm -rf /opt/bundle/bundle/programs/web.browser.legacy/ \\
    && rm -f /opt/bundle/bundle/main.js \\
    && rm -f /opt/bundle/bundle/star.json

# Copy new source code files (light replacement)
COPY bundle/programs/server/app/ /opt/bundle/bundle/programs/server/app/
COPY bundle/programs/server/packages/ /opt/bundle/bundle/programs/server/packages/
COPY bundle/programs/web.browser/ /opt/bundle/bundle/programs/web.browser/
COPY bundle/programs/web.browser.legacy/ /opt/bundle/bundle/programs/web.browser.legacy/
COPY bundle/main.js /opt/bundle/bundle/main.js
COPY bundle/star.json /opt/bundle/bundle/star.json

# Fix ownership after light update
RUN chown -R meteor:meteor /opt/bundle/bundle/ \\
    && if [ -d /opt/src ]; then chown -R meteor:meteor /opt/src/; fi

# Switch back to meteor user
USER meteor

# Working directory is already set in base image
WORKDIR /opt/bundle/bundle
EOF

# Copy migration files (these might change with source) - only if they exist
if [ -f migrate-mongo-config.js ]; then
    cp migrate-mongo-config.js "${DEPLOY_DIR}/"
    echo "COPY migrate-mongo-config.js /opt/src/" >> "${DEPLOY_DIR}/Dockerfile"
fi

if [ -d migrations ]; then
    mkdir -p "${DEPLOY_DIR}/migrations"
    find migrations -name "*.js" -type f -exec cp {} "${DEPLOY_DIR}/migrations/" \;
    echo "COPY migrations/ /opt/src/migrations/" >> "${DEPLOY_DIR}/Dockerfile"
fi

# Create captain definition
echo '{"schemaVersion": 2,"dockerfilePath": "./Dockerfile"}' > "${DEPLOY_DIR}/captain-definition"

print_status "📦 Creating deployment tarball..."

# Create tarball for CapRover deployment
TARBALL_NAME="invoicing.tar.gz"
tar -czf "${TARBALL_NAME}" -C "${DEPLOY_DIR}" .

print_status "🚀 Deploying light image to CapRover via tarball..."

# Deploy using CapRover CLI with tarball
if [ -n "$CAPROVER_APP" ]; then
    if command -v caprover >/dev/null 2>&1; then
        if caprover list >/dev/null 2>&1; then
            print_status "Using CapRover CLI to deploy tarball..."
            caprover deploy --tarFile "${TARBALL_NAME}"
            DEPLOY_RESULT=$?
        else
            print_error "CapRover CLI not configured. Run 'caprover serversetup' first"
            DEPLOY_RESULT=1
        fi
    else
        print_error "CapRover CLI not found. Install with: npm install -g caprover"
        DEPLOY_RESULT=1
    fi
else
    print_warning "⚠️  CapRover deployment skipped - set CAPROVER_APP environment variable"
    DEPLOY_RESULT=1
fi

print_status ""
print_status "🎉 LIGHT deployment completed successfully!"
print_status "⚡ Local build: ~40s + Upload: ~10s + CapRover rebuild: ~2-3min = ~4min total"
print_status "🐌 vs Full CI build: ~20 minutes (5x faster!)"
print_status ""
print_status "🔄 To rebuild base image (when dependencies change):"
print_status "   GitHub Actions → 'Build Full Production Image' workflow"

How the Light Deployment Works

The key insight is that most deployments only change source code, not dependencies. The script:

  1. Builds Meteor locally (~40s) - Much faster than CI since it’s on your development machine
  2. Identifies changed files - Only the compiled source code parts of the bundle
  3. Creates a light Dockerfile that overlays just the changed files onto the existing production image
  4. Packages as tarball and uploads to CapRover (~10s)
  5. CapRover rebuilds and switches over (~2-3 minutes)

Total time: ~4 minutes vs 20+ minutes (5x faster)

The light Dockerfile specifically targets these directories that change with source code:

  • programs/server/app/ - Server-side application code
  • programs/server/packages/ - Meteor packages
  • programs/web.browser/ - Client-side assets from Vite
  • programs/web.browser.legacy/ - Legacy browser support
  • main.js and star.json - Entry points and metadata

SolidJS + Vite Integration

Our package.json shows the modern stack configuration:

{
  "dependencies": {
    "@solidjs/meta": "^0.29.4",
    "@solidjs/router": "^0.15.3",
    "meteor-vite": "^3.8.0",
    "solid-js": "^1.9.4",
    "vite": "^6.3.5",
    "vite-plugin-solid": "^2.11.0"
  },
  "meteor": {
    "mainModule": {
      "client": "client/entry-meteor.js",
      "server": "server/entry-meteor.js"
    },
    "modern": true
  },
  "scripts": {
    "dev": "meteor run --port ${PORT:-3000}",
    "build:production": "meteor build ../build --directory --server-only",
    "deploy": "./quickdeploy.sh"
  }
}

The meteor-vite package, created by Jørgen Vatle, bridges Meteor with Vite seamlessly, enabling:

  • Fast HMR during development
  • Optimized production builds with tree shaking
  • SolidJS integration through vite-plugin-solid

Performance Results

Here’s the dramatic improvement we achieved:

Deployment TypeTimeUse Case
Full CI Build~20 minutesDependency changes, major updates
Light Deployment~4 minutesCode changes, bug fixes, features
Speed Improvement5x faster90% of deployments

When to Use Each Approach

Use Full CI Build when:

  • Adding/updating npm dependencies
  • Upgrading Meteor, SolidJS, or Vite versions
  • Changing Docker configuration
  • Major architectural changes

Use Light Deployment when:

  • Bug fixes
  • New features (pure code changes)
  • UI updates
  • Configuration tweaks
  • Database migrations

Getting Started

To implement this in your own Meteor v3 project:

  1. Create the base image using our Dockerfile as a template
  2. Set up GitHub Actions with the workflow above
  3. Copy the quickdeploy.sh script and adjust paths/image names
  4. Configure CapRover CLI with caprover serversetup
  5. Set environment variable CAPROVER_APP=your-app-name

Now you can deploy code changes in under a minute:

./quickdeploy.sh

And rebuild the base image when dependencies change via GitHub Actions.

Our Stack Choices (Adapt to Your Needs)

The implementation above reflects specific choices we made for our free invoicing service. These can be easily adapted to your infrastructure:

Container Orchestration: We chose CapRover running on Docker Swarm for its simplicity and self-hosted nature. CapRover provides a great developer experience with automatic SSL, easy deployments, and a clean web UI. You could easily adapt this approach to:

  • Kubernetes clusters (modify the deployment scripts to use kubectl instead of CapRover CLI)
  • AWS ECS/Fargate (adjust the GitHub Actions to push to ECR and update ECS services)
  • Google Cloud Run (modify to deploy container images directly)
  • Azure Container Instances (adapt the deployment pipeline accordingly)

Image Registry: We use GitHub Container Registry (ghcr.io) to host our full production images privately. This integrates seamlessly with GitHub Actions and provides free private hosting. Alternatives include:

  • Docker Hub (public or private repositories)
  • AWS ECR (private registry with fine-grained permissions)
  • Google Container Registry (GCR) or Artifact Registry
  • Azure Container Registry (ACR)

Cloud Infrastructure: We deploy on Oracle Cloud’s Always Free tier, which provides generous ARM64 compute resources at no cost—perfect for hosting free services. The 4 OCPU ARM64 instances with 24GB RAM handle our traffic beautifully. Other ARM64-friendly options:

  • AWS Graviton instances (t4g, c6g, m6g families)
  • Google Cloud Tau T2A (ARM-based VMs)
  • Azure Ampere VMs
  • Hetzner Cloud ARM64 instances (excellent price/performance)

The beauty of this approach is that the core concept—separating dependency builds from code changes—works regardless of your infrastructure choices. Simply adapt the deployment scripts and registry configurations to match your preferred stack.

Conclusion

This two-tier deployment strategy transformed our development workflow. The 5x speed improvement for regular deployments means we can iterate faster, deploy more frequently, and spend less time waiting for builds.

The combination of Meteor 3.3, SolidJS, Vite, and ARM64 creates a powerful modern stack. With the right CI/CD approach, it can also be lightning fast.

For teams using similar modern JavaScript stacks, consider adopting this pattern: heavy lifting in CI for dependencies, light overlays for code. Your productivity will thank you.