This guide walks you through deploying your Nuda Kit backend to DigitalOcean using App Platform, Container Registry, and managed databases.
The deployment architecture consists of:
| Component | Service | Purpose |
|---|---|---|
| Web Service | App Platform | Serves the API |
| Email Worker | App Platform Worker | Processes email queue |
| User Deletions Worker | App Platform Worker | Processes user deletion queue |
| PostgreSQL | Managed Database | Primary database |
| Valkey | Marketplace Droplet | Redis-compatible queue storage |
Before starting, ensure you have:
Before deploying, ensure you have the following files in your backend repository.
Create a Dockerfile in your backend folder:
# syntax=docker/dockerfile:1
# ========================================
# Stage 1: Build
# ========================================
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies needed for native modules
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package.json package-lock.json ./
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN node ace build
# ========================================
# Stage 2: Production
# ========================================
FROM node:22-alpine AS production
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S adonis -u 1001
# Copy package files
COPY package.json package-lock.json ./
# Install production dependencies only
RUN npm ci --omit=dev && \
npm cache clean --force
# Copy built application from builder stage
COPY --from=builder /app/build ./
# Copy entrypoint script
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
# Change ownership to non-root user
RUN chown -R adonis:nodejs /app
# Switch to non-root user
USER adonis
# Expose the application port
EXPOSE 3333
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3333/health || exit 1
# Start the application
CMD ["./docker-entrypoint.sh"]
Create .github/workflows/build.yml in your backend repository:
name: Deploy to DigitalOcean
on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY: registry.digitalocean.com
IMAGE_NAME: nudakit-backend
jobs:
build-and-push:
name: Build and Push Image
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Log in to DigitalOcean Container Registry
run: doctl registry login --expiry-seconds 1200
- name: Retag current latest as previous (for rollback)
run: |
# Pull current latest and retag as previous (ignore if doesn't exist yet)
docker pull ${{ env.REGISTRY }}/${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:latest || true
docker tag ${{ env.REGISTRY }}/${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:latest \
${{ env.REGISTRY }}/${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:previous || true
docker push ${{ env.REGISTRY }}/${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:previous || true
- name: Build Docker image
run: |
docker build \
-t ${{ env.REGISTRY }}/${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:latest \
.
- name: Push image to DigitalOcean Container Registry
run: |
docker push ${{ env.REGISTRY }}/${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:latest
- name: Delete untagged manifests
run: |
doctl registry repository list-manifests ${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }} --output json | \
jq -r '.[] | select(.tags == null or .tags == []) | .digest' | \
xargs -I {} doctl registry repository delete-manifest ${{ secrets.DIGITALOCEAN_REGISTRY_NAME }}/${{ env.IMAGE_NAME }} {} --force || true
- name: Run garbage collection to free up space
run: |
doctl registry garbage-collection start --include-untagged-manifests --force
This workflow:
main or manually via workflow_dispatchDigitalOcean Container Registry stores your Docker images.
your-company)Generate an API token for GitHub Actions to push images.
GitHub Actions)Add the required secrets to your GitHub repository.
| Secret Name | Value |
|---|---|
DIGITALOCEAN_ACCESS_TOKEN | Your API token from Step 2 |
DIGITALOCEAN_REGISTRY_NAME | Your registry name (e.g., your-company) |
The included GitHub Actions workflow automatically builds and pushes your Docker image when you push to main.
Trigger a deployment:
git add .
git commit -m "Deploy to production"
git push origin main
Verify the image was pushed:
nudakit-backend repository with a latest tagprevious tag for easy rollbacks.Nuda Kit uses Redis-compatible storage for queues. DigitalOcean offers Valkey, a Redis-compatible database.
your-valkey-droplet-ip)6379)nudakit-backend repositorylatest tag3333DigitalOcean will automatically inject the database connection as environment variables.
Click on your web service component and go to the Environment Variables section.
Add the following variables:
| Variable | Value |
|---|---|
NODE_ENV | production |
HOST | 0.0.0.0 |
PORT | 3333 |
APP_KEY | Generate with node ace generate:key locally |
APP_URL | https://your-app.ondigitalocean.app (your app URL) |
FRONTEND_URL | https://your-frontend.vercel.app (your Vercel URL) |
LOG_LEVEL | info |
DigitalOcean injects these automatically when you add a database, but verify they match:
| Variable | Value |
|---|---|
DB_HOST | ${db.HOSTNAME} |
DB_PORT | ${db.PORT} |
DB_USER | ${db.USERNAME} |
DB_PASSWORD | ${db.PASSWORD} |
DB_DATABASE | ${db.DATABASE} |
| Variable | Value |
|---|---|
QUEUE_REDIS_HOST | Your Valkey droplet IP |
QUEUE_REDIS_PORT | 6379 |
QUEUE_REDIS_PASSWORD | Your Valkey password (if set) |
| Variable | Value |
|---|---|
MAIL_DRIVER | resend |
MAIL_FROM_NAME | Your App Name |
MAIL_FROM_ADDRESS | noreply@yourdomain.com |
RESEND_API_KEY | Your Resend API key |
| Variable | Value |
|---|---|
STRIPE_SECRET_KEY | sk_live_xxx (your live key) |
STRIPE_WEBHOOK_SECRET | We'll get this in Step 11 |
STRIPE_API_VERSION | 2025-10-29.clover |
STRIPE_DEVICE_NAME | production |
STRIPE_EVENTS_WHITELIST | checkout.session.completed,invoice.paid,invoice.payment_failed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted |
| Variable | Value |
|---|---|
LIMITER_STORE | database |
DRIVE_DISK | fs (or s3 if using S3) |
SWAGGER_USERNAME | Your swagger docs username |
SWAGGER_PASSWORD | Your swagger docs password |
SERVICE_TYPE | web |
The email worker processes the email queue in the background.
nudakit-backend repository and latest tagemail-workerThe worker needs the same environment variables as the web service, plus:
| Variable | Value |
|---|---|
SERVICE_TYPE | worker |
QUEUE_NAME | emails |
For the workers to use the QUEUE_NAME environment variable, update your docker-entrypoint.sh to:
#!/bin/sh
set -e
SERVICE_TYPE=${SERVICE_TYPE:-web}
QUEUE_NAME=${QUEUE_NAME:-emails}
echo "Running migrations..."
node ace migration:run --force
if [ "$SERVICE_TYPE" = "worker" ]; then
echo "Starting Queue Worker for queue: $QUEUE_NAME"
exec node ace queue:listen --queue=$QUEUE_NAME
else
echo "Starting Web Server..."
exec node bin/server.js
fi
This allows you to set QUEUE_NAME as an environment variable in DigitalOcean to specify which queue each worker should process.
Repeat the same process for the user deletions queue:
nudakit-backend repositoryuser-deletions-workerSame as the email worker, but with:
| Variable | Value |
|---|---|
SERVICE_TYPE | worker |
QUEUE_NAME | user_deletions |
In production, you need to set up webhooks in the Stripe Dashboard instead of using the CLI.
https://your-app.ondigitalocean.app/webhooks/stripe
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedSTRIPE_WEBHOOK_SECRET environment variable with this valuesk_live_) and create products/prices in live mode for production.Once deployed, your app URL will be displayed (e.g., https://your-app.ondigitalocean.app).
Test that everything is working:
https://your-app.ondigitalocean.app/healthhttps://your-app.ondigitalocean.app/docs (if swagger is enabled)API_BASE_URL to your new backend URLTo use your own domain:
api.yourdomain.com)DigitalOcean will automatically provision an SSL certificate.
Every push to main will:
latest tagprevious for rollbacksTo enable auto-deploy in App Platform:
| Variable | Required | Description |
|---|---|---|
NODE_ENV | Yes | production |
APP_KEY | Yes | Encryption key (generate with node ace generate:key) |
APP_URL | Yes | Your backend URL |
FRONTEND_URL | Yes | Your frontend URL (for CORS) |
DB_* | Yes | Database connection (auto-injected) |
QUEUE_REDIS_* | Yes | Valkey/Redis connection |
MAIL_DRIVER | Yes | resend for production |
RESEND_API_KEY | Yes | Resend API key |
STRIPE_SECRET_KEY | Yes | Live Stripe secret key |
STRIPE_WEBHOOK_SECRET | Yes | From Stripe Dashboard |
SERVICE_TYPE | Yes | web or worker |
QUEUE_NAME | Workers | emails or user_deletions |
| Issue | Solution |
|---|---|
| App won't start | Check logs in App Platform for errors |
| Database connection failed | Verify DB environment variables are using ${db.*} syntax |
| Queues not processing | Check worker logs and Valkey connection |
| Stripe webhooks failing | Verify webhook URL and signing secret |
| Emails not sending | Check Resend API key and domain verification |
| CORS errors from frontend | Ensure FRONTEND_URL matches your Vercel URL |
If something goes wrong, rollback to the previous image:
previous tagprevious tag instead of latest| Resource | Monthly Cost |
|---|---|
| App Platform (Web Service) | ~$5-12 |
| App Platform (2 Workers) | ~$10-24 |
| PostgreSQL (Dev) | Free |
| PostgreSQL (Managed) | ~$15+ |
| Valkey Droplet | ~$6 |
| Total (Starter) | ~$21-47/mo |
Need help with your deployment? Drop a message in the #deployment-help channel and we'll assist you directly.