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:
- Full Base Image: Built via GitHub Actions when dependencies change (~20 minutes)
- 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 $SCRIPTS_FOLDER $SCRIPTS_FOLDER/
# Copy source files needed at runtime (migrations, config)
RUN mkdir -p $APP_SOURCE_FOLDER
COPY /tmp/migrations-bundle/ $APP_SOURCE_FOLDER/
# Copy the built app bundle from builder stage
COPY $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 $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 \
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:
- Builds Meteor locally (~40s) - Much faster than CI since it’s on your development machine
- Identifies changed files - Only the compiled source code parts of the bundle
- Creates a light Dockerfile that overlays just the changed files onto the existing production image
- Packages as tarball and uploads to CapRover (~10s)
- 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 codeprograms/server/packages/
- Meteor packagesprograms/web.browser/
- Client-side assets from Viteprograms/web.browser.legacy/
- Legacy browser supportmain.js
andstar.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 Type | Time | Use Case |
---|---|---|
Full CI Build | ~20 minutes | Dependency changes, major updates |
Light Deployment | ~4 minutes | Code changes, bug fixes, features |
Speed Improvement | 5x faster | 90% 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:
- Create the base image using our Dockerfile as a template
- Set up GitHub Actions with the workflow above
- Copy the quickdeploy.sh script and adjust paths/image names
- Configure CapRover CLI with
caprover serversetup
- 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.