Deploying Express.js Apps on DigitalOcean App Platform
A step-by-step guide to deploying Express.js applications on DigitalOcean App Platform, covering app specs, auto-deploy from GitHub, managed databases, custom domains, and cost optimization.
Deploying Express.js Apps on DigitalOcean App Platform
Overview
DigitalOcean App Platform is a Platform-as-a-Service (PaaS) that builds, deploys, and manages your application from a Git repository. For Express.js developers who want to ship code without configuring Nginx, managing SSL certificates, writing systemd unit files, or patching Ubuntu every month, App Platform handles all of that with a single YAML file. I have been running production Node.js applications on it for over a year and it has replaced my Droplet-based deployments entirely.
Prerequisites
- A DigitalOcean account with billing configured
- Node.js 18+ and npm installed locally
- An Express.js application with a
package.jsonthat defines astartscript - A GitHub (or GitLab/Bitbucket) repository containing your application
doctlCLI installed (optional but recommended for managing apps from your terminal)- Basic familiarity with YAML syntax
Why App Platform Over Droplets
If you have deployed Node.js on a Droplet before, you know the drill: provision a server, install Node, configure Nginx as a reverse proxy, obtain SSL certificates with Certbot, set up PM2 for process management, configure firewall rules, and maintain the OS. That works, and for some workloads it is the right choice. But for a standard Express.js web application or API, it is a lot of operational overhead that adds zero business value.
App Platform eliminates all of that. Here is what you get out of the box:
- Automatic builds from your Git repository on every push
- Zero-downtime deployments with automatic rollback on failure
- Managed SSL certificates (Let's Encrypt) with automatic renewal
- Built-in CDN for static assets
- Vertical and horizontal scaling with a slider
- Managed databases (PostgreSQL, MySQL, MongoDB, Redis) that integrate directly
- Log aggregation and basic monitoring included
- DDoS protection at the platform level
The tradeoff is reduced control. You cannot SSH into the runtime container, you cannot install arbitrary system packages (outside of the build phase), and you are constrained to the buildpacks and runtimes that App Platform supports. For most Express.js applications, none of that matters.
My rule of thumb: If your application is a web server or API that reads from a database and responds to HTTP requests, use App Platform. If you need persistent background workers, custom binary dependencies, or precise control over the networking stack, use a Droplet with Docker.
The App Spec: Your Deployment Blueprint
Everything in App Platform revolves around the app spec -- a YAML file that defines your entire application topology. You can create it through the web console, but I strongly recommend keeping it in your repository as .do/app.yaml. This gives you version-controlled infrastructure and makes it trivial to reproduce your setup.
Minimal App Spec
Here is the simplest possible app spec for an Express.js application:
name: my-express-app
region: nyc
services:
- name: web
github:
repo: your-username/my-express-app
branch: main
deploy_on_push: true
source_dir: /
environment_slug: node-js
instance_count: 1
instance_size_slug: apps-s-1vcpu-0.5gb
http_port: 8080
routes:
- path: /
That is it. Push this to your repository, connect it in the App Platform console, and your Express.js app is live with HTTPS.
Let me break down each field:
| Field | Purpose |
|---|---|
name |
Your app's identifier in the DigitalOcean dashboard |
region |
Datacenter region (nyc, sfo, ams, sgp, lon, fra, tor, blr, syd) |
github.repo |
Your GitHub repository in owner/repo format |
github.branch |
The branch to deploy from |
deploy_on_push |
Automatically redeploy when you push to this branch |
source_dir |
Root directory of your app within the repo (useful for monorepos) |
environment_slug |
Runtime environment -- node-js for Node.js apps |
instance_count |
Number of containers to run |
instance_size_slug |
Container size (CPU/RAM) |
http_port |
The port your Express app listens on |
routes |
URL path prefixes this service handles |
Your Express App Must Listen on the Right Port
App Platform routes traffic to whatever port you specify in http_port. Your Express app needs to listen on that port. This is the most common deployment failure I see:
var express = require('express');
var app = express();
var PORT = process.env.PORT || 8080;
app.get('/', function(req, res) {
res.json({ status: 'running', timestamp: new Date().toISOString() });
});
app.listen(PORT, function() {
console.log('Server listening on port ' + PORT);
});
Use process.env.PORT as the primary value and fall back to a sensible default. App Platform sets the PORT environment variable automatically, but if you also set http_port in your app spec, make sure they agree. I always use 8080 for both and have never had an issue.
Environment Variables and Secrets
You will almost certainly need environment variables for database connection strings, API keys, and configuration values. App Platform supports two types:
Plain Variables
envs:
- key: NODE_ENV
scope: RUN_TIME
value: production
- key: LOG_LEVEL
scope: RUN_TIME
value: info
Secrets
For sensitive values like API keys and database credentials, use type: SECRET. These values are encrypted at rest and masked in logs and the dashboard:
envs:
- key: DATABASE_URL
scope: RUN_TIME
type: SECRET
- key: OPENAI_API_KEY
scope: RUN_TIME
type: SECRET
- key: JWT_SECRET
scope: RUN_TIME
type: SECRET
When you use type: SECRET, you do not put the value in the YAML file. You set it through the dashboard or the doctl CLI:
doctl apps update <app-id> --spec .do/app.yaml
Then set the secret values in the console under Settings > App-Level Environment Variables, or via the API.
Variable Scopes
The scope field controls when the variable is available:
| Scope | Available During Build | Available at Runtime |
|---|---|---|
RUN_TIME |
No | Yes |
BUILD_TIME |
Yes | No |
RUN_AND_BUILD_TIME |
Yes | Yes |
Use RUN_TIME for most things. Use BUILD_TIME for variables that influence your build process (like NPM_TOKEN for private registries). Use RUN_AND_BUILD_TIME sparingly -- there is rarely a reason a build-time variable also needs to exist at runtime.
Accessing Variables in Express
Nothing special here. They are standard environment variables:
var express = require('express');
var app = express();
var config = {
port: process.env.PORT || 8080,
nodeEnv: process.env.NODE_ENV || 'development',
databaseUrl: process.env.DATABASE_URL,
openaiKey: process.env.OPENAI_API_KEY,
logLevel: process.env.LOG_LEVEL || 'info'
};
if (!config.databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
app.listen(config.port, function() {
console.log('Server started in ' + config.nodeEnv + ' mode on port ' + config.port);
});
Connecting GitHub for Auto-Deploy
The GitHub integration is one of App Platform's best features. Once connected, every push to your configured branch triggers a new deployment automatically.
Setting It Up
- In the DigitalOcean console, go to Apps > Create App
- Select GitHub as your source
- Authorize DigitalOcean to access your repositories (you can limit access to specific repos)
- Select your repository and branch
- App Platform auto-detects your
package.jsonand configures the build
Build and Run Commands
App Platform uses Cloud Native Buildpacks to build your application. For a Node.js app, it automatically runs:
- Build:
npm install(ornpm ciifpackage-lock.jsonexists) - Run: Whatever is in your
package.jsonstartscript
You can override these in the app spec:
services:
- name: web
build_command: npm ci --production
run_command: node app.js
I recommend setting an explicit run_command rather than relying on the start script detection. It removes one layer of indirection and makes the app spec self-documenting.
Deploy on Push vs Manual Deploys
With deploy_on_push: true, every push to the configured branch triggers a deployment. This is great for solo projects and small teams. For larger teams, you might want to disable it and use the CLI or API to trigger deploys from your CI/CD pipeline:
# Trigger a deployment manually
doctl apps create-deployment <app-id>
# Force a deployment from a specific commit
doctl apps create-deployment <app-id> --force-rebuild
Monitoring Build Progress
You can watch builds in real time from the CLI:
doctl apps logs <app-id> --type build --follow
A typical build log for a Node.js app looks like this:
[2026-02-08 14:23:01] => Detecting platform
[2026-02-08 14:23:02] => Node.js 20.17.0 detected
[2026-02-08 14:23:02] => Installing dependencies
[2026-02-08 14:23:15] added 147 packages in 12s
[2026-02-08 14:23:16] => Build complete
[2026-02-08 14:23:18] => Deploying...
[2026-02-08 14:23:45] => Deploy complete. Service is live.
Custom Domains and SSL
App Platform provides a default URL like my-express-app-xxxxx.ondigitalocean.app. For production, you want your own domain.
Adding a Custom Domain
Add the domains section to your app spec:
name: my-express-app
region: nyc
domains:
- domain: myapp.com
type: PRIMARY
- domain: www.myapp.com
type: ALIAS
services:
- name: web
# ... rest of config
DNS Configuration
After adding the domain in your app spec, you need to point your DNS records to DigitalOcean:
For an apex domain (e.g., myapp.com):
- Add an
Arecord pointing to174.138.43.194(or the IP shown in your dashboard) - Or better, use DigitalOcean as your DNS provider and add a
CNAMEto@
For a subdomain (e.g., www.myapp.com or api.myapp.com):
- Add a
CNAMErecord pointing tomy-express-app-xxxxx.ondigitalocean.app
SSL Certificates
SSL is completely automatic. App Platform provisions a Let's Encrypt certificate for your custom domain and handles renewal. There is nothing to configure, no Certbot to install, no cron jobs. It just works.
One thing to note: the initial certificate provisioning can take up to 10-15 minutes after you configure DNS. During that window, HTTPS requests to your custom domain will fail. Do not panic -- just wait.
Health Checks
Health checks tell App Platform whether your application is healthy. If the health check fails, the platform will restart your container and, during deployments, will not route traffic to unhealthy instances.
Adding a Health Check Endpoint
First, add a health check route to your Express app:
var express = require('express');
var app = express();
// Simple health check
app.get('/healthz', function(req, res) {
res.status(200).json({ status: 'ok' });
});
// More thorough health check that verifies dependencies
app.get('/readyz', function(req, res) {
var checks = {
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString()
};
// Verify database connectivity
db.query('SELECT 1')
.then(function() {
checks.database = 'connected';
res.status(200).json(checks);
})
.catch(function(err) {
checks.database = 'disconnected';
checks.error = err.message;
res.status(503).json(checks);
});
});
Configuring in the App Spec
services:
- name: web
health_check:
http_path: /healthz
initial_delay_seconds: 10
period_seconds: 30
timeout_seconds: 5
success_threshold: 1
failure_threshold: 3
This configuration tells App Platform to:
- Wait 10 seconds after starting the container before checking
- Send a GET request to
/healthzevery 30 seconds - Consider the check failed if it does not respond within 5 seconds
- Mark the instance as healthy after 1 successful check
- Mark the instance as unhealthy after 3 consecutive failures
I always set initial_delay_seconds to at least 10 for Express apps. If your app needs to establish database connections or load cached data on startup, increase it to 20-30 seconds.
Managing Multiple Environments
Running separate staging and production environments is essential. App Platform makes this straightforward with separate apps that share the same codebase but deploy from different branches.
Strategy: Branch-Based Environments
main branch → production app
staging branch → staging app
Create two app specs:
.do/app.yaml (production):
name: myapp-production
region: nyc
domains:
- domain: myapp.com
type: PRIMARY
services:
- name: web
github:
repo: your-username/myapp
branch: main
deploy_on_push: true
environment_slug: node-js
instance_count: 2
instance_size_slug: apps-s-1vcpu-1gb
http_port: 8080
envs:
- key: NODE_ENV
scope: RUN_TIME
value: production
.do/app-staging.yaml (staging):
name: myapp-staging
region: nyc
services:
- name: web
github:
repo: your-username/myapp
branch: staging
deploy_on_push: true
environment_slug: node-js
instance_count: 1
instance_size_slug: apps-s-1vcpu-0.5gb
http_port: 8080
envs:
- key: NODE_ENV
scope: RUN_TIME
value: staging
Notice the staging app uses a smaller instance size and only one container. There is no reason to pay for production-grade resources in staging.
Using doctl to Manage Both
# Deploy to staging
doctl apps update <staging-app-id> --spec .do/app-staging.yaml
# Deploy to production
doctl apps update <production-app-id> --spec .do/app.yaml
# List all apps
doctl apps list --format ID,DefaultIngress,ActiveDeployment.Phase
Adding Managed Databases
App Platform integrates with DigitalOcean Managed Databases. You can add PostgreSQL, MySQL, MongoDB, or Redis directly in your app spec, or attach an existing database cluster.
Adding a Dev Database (Built-in)
For development and small projects, App Platform offers built-in dev databases at no additional cost (included in your app's pricing):
databases:
- name: db
engine: PG
production: false
cluster_name: db
db_name: myapp
db_user: myapp
Setting production: false creates a development-tier database. It is single-node, has limited storage, and is not backed up. Do not use this for production data.
Attaching a Production Database
For production, create a managed database cluster separately and reference it:
databases:
- name: db
engine: PG
production: true
cluster_name: myapp-db-cluster
db_name: myapp
db_user: myapp
Or reference an existing cluster by ID:
# Create a managed PostgreSQL cluster
doctl databases create myapp-db \
--engine pg \
--region nyc1 \
--size db-s-1vcpu-1gb \
--num-nodes 1 \
--version 16
Connection String Binding
When you add a database to your app spec, App Platform automatically injects a DATABASE_URL environment variable. You can reference it in your Express app:
var { Pool } = require('pg');
var pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: false
}
});
pool.query('SELECT NOW()', function(err, result) {
if (err) {
console.error('Database connection failed:', err.message);
} else {
console.log('Database connected at:', result.rows[0].now);
}
});
Important: Managed databases on DigitalOcean require SSL connections. Always include ssl: { rejectUnauthorized: false } in your connection configuration, or better yet, download the CA certificate and use it:
var fs = require('fs');
var pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
ca: fs.readFileSync('/path/to/ca-certificate.crt').toString()
}
});
Adding MongoDB
If you prefer MongoDB, the pattern is similar:
databases:
- name: mongodb
engine: MONGODB
production: true
cluster_name: myapp-mongo
db_name: myapp
db_user: myapp
var mongoose = require('mongoose');
var mongoUrl = process.env.MONGODB_URL || process.env.DATABASE_URL;
mongoose.connect(mongoUrl, {
ssl: true,
tlsAllowInvalidCertificates: false
}).then(function() {
console.log('MongoDB connected');
}).catch(function(err) {
console.error('MongoDB connection error:', err.message);
});
Log Management and Monitoring
Viewing Logs
App Platform aggregates stdout and stderr from your containers. You can view logs in the console or via the CLI:
# Follow runtime logs in real time
doctl apps logs <app-id> --type run --follow
# View build logs for the most recent deployment
doctl apps logs <app-id> --type build
# View deploy-phase logs
doctl apps logs <app-id> --type deploy
Structured Logging in Express
Plain console.log works, but structured JSON logging makes your logs searchable and parseable:
var logger = {
info: function(message, data) {
console.log(JSON.stringify({
level: 'info',
message: message,
data: data || {},
timestamp: new Date().toISOString()
}));
},
error: function(message, error) {
console.error(JSON.stringify({
level: 'error',
message: message,
error: error ? error.message : undefined,
stack: error ? error.stack : undefined,
timestamp: new Date().toISOString()
}));
},
warn: function(message, data) {
console.warn(JSON.stringify({
level: 'warn',
message: message,
data: data || {},
timestamp: new Date().toISOString()
}));
}
};
// Usage
logger.info('Server started', { port: 8080, env: process.env.NODE_ENV });
logger.error('Database query failed', err);
Request Logging Middleware
var requestLogger = function(req, res, next) {
var start = Date.now();
res.on('finish', function() {
var duration = Date.now() - start;
logger.info('request', {
method: req.method,
path: req.originalUrl,
status: res.statusCode,
duration: duration + 'ms',
ip: req.ip,
userAgent: req.get('user-agent')
});
});
next();
};
app.use(requestLogger);
Built-in Monitoring
App Platform provides basic metrics in the console:
- CPU usage per container
- Memory usage per container
- Bandwidth consumed
- Request count and response times (if using the built-in load balancer)
- Restart count (indicates health check failures)
For more advanced monitoring, integrate with a third-party service like Datadog, New Relic, or even a self-hosted Prometheus/Grafana stack. You send metrics from your Express app via their Node.js SDKs.
Scaling Options
Vertical Scaling (Bigger Containers)
Change the instance_size_slug in your app spec:
| Slug | CPU | RAM | Monthly Cost |
|---|---|---|---|
apps-s-1vcpu-0.5gb |
1 vCPU | 512 MB | ~$5 |
apps-s-1vcpu-1gb |
1 vCPU | 1 GB | ~$10 |
apps-s-1vcpu-2gb |
1 vCPU | 2 GB | ~$20 |
apps-s-2vcpu-4gb |
2 vCPU | 4 GB | ~$40 |
For most Express.js APIs serving JSON responses, 1 vCPU and 1 GB of RAM handles a surprising amount of traffic. Node.js is single-threaded, so adding more CPUs only helps if you are using the cluster module or running CPU-intensive tasks.
Horizontal Scaling (More Containers)
Increase instance_count to run multiple containers behind the load balancer:
services:
- name: web
instance_count: 3
instance_size_slug: apps-s-1vcpu-1gb
Horizontal scaling requires your application to be stateless. That means:
- No in-memory sessions (use Redis or database-backed sessions)
- No local file storage (use object storage like DigitalOcean Spaces)
- No in-memory caches that cannot tolerate inconsistency across instances
If your Express app stores sessions in memory using express-session with the default MemoryStore, switching to multiple instances will cause session loss. Fix this before scaling horizontally:
var session = require('express-session');
var pgSession = require('connect-pg-simple')(session);
var { Pool } = require('pg');
var pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
app.use(session({
store: new pgSession({
pool: pool,
tableName: 'user_sessions'
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
secure: true,
httpOnly: true
}
}));
Autoscaling
As of early 2026, App Platform supports autoscaling based on CPU metrics. You can configure it in the app spec:
services:
- name: web
instance_count: 1
autoscaling:
min_instance_count: 1
max_instance_count: 5
metrics:
cpu:
percent: 70
This scales your service between 1 and 5 instances, adding containers when average CPU exceeds 70%.
Cost Optimization Tips
App Platform pricing can creep up if you are not careful. Here are the strategies I use to keep costs reasonable:
1. Use the smallest instance size that works. Start with apps-s-1vcpu-0.5gb ($5/month) and only upgrade when you actually hit resource limits. Monitor your CPU and memory usage for a week before sizing up.
2. Use dev databases for non-production environments. Dev databases are included in your app cost. Production managed databases start at $15/month. Do not pay for a production database in your staging environment.
3. Consolidate static assets. If your Express app serves static files, consider offloading them to DigitalOcean Spaces (S3-compatible object storage at $5/month for 250GB) with a CDN in front. This reduces load on your compute instances.
4. Disable auto-deploy on branches you push frequently. Every push triggers a build, and builds consume build minutes. If you push to your staging branch 20 times a day during active development, you are burning through build minutes. Trigger deploys manually from CI instead.
5. Review your app list monthly. It is easy to forget about that test app you spun up three weeks ago. Run doctl apps list periodically and tear down anything you are not using.
6. Right-size your database. A managed PostgreSQL cluster at the smallest tier ($15/month) handles far more load than most Express.js applications will ever generate. Do not over-provision.
7. Use the App Platform Starter tier when possible. For side projects and low-traffic sites, the Starter plan ($5/month per container) is sufficient and significantly cheaper than Pro.
Handling Static Assets
Express.js apps typically serve static files -- CSS, JavaScript, images. App Platform handles this fine, but there are better patterns than serving everything through your Node.js process.
Serving Static Files from Express
The simplest approach. Fine for small sites:
var express = require('express');
var path = require('path');
var app = express();
// Serve static files with caching headers
app.use('/static', express.static(path.join(__dirname, 'static'), {
maxAge: '7d',
etag: true,
lastModified: true
}));
Using App Platform's Static Site Component
For larger applications, split your static assets into a separate static site component. App Platform serves these from its CDN, which is faster and cheaper than routing them through your Node.js process:
name: my-express-app
region: nyc
services:
- name: api
github:
repo: your-username/myapp
branch: main
deploy_on_push: true
environment_slug: node-js
instance_size_slug: apps-s-1vcpu-0.5gb
http_port: 8080
routes:
- path: /api
static_sites:
- name: frontend
github:
repo: your-username/myapp
branch: main
deploy_on_push: true
source_dir: /static
output_dir: /
routes:
- path: /
This configuration serves your API from /api through the Node.js service and everything else from the static site component via CDN.
Complete Working Example
Here is a complete app spec for a production Express.js application with a managed PostgreSQL database, custom domain, health check, environment variables, and autoscaling:
name: myapp-production
region: nyc
domains:
- domain: myapp.com
type: PRIMARY
- domain: www.myapp.com
type: ALIAS
services:
- name: web
github:
repo: your-username/myapp
branch: main
deploy_on_push: true
source_dir: /
environment_slug: node-js
instance_count: 2
instance_size_slug: apps-s-1vcpu-1gb
http_port: 8080
build_command: npm ci --production
run_command: node app.js
routes:
- path: /
health_check:
http_path: /healthz
initial_delay_seconds: 15
period_seconds: 30
timeout_seconds: 5
success_threshold: 1
failure_threshold: 3
autoscaling:
min_instance_count: 2
max_instance_count: 5
metrics:
cpu:
percent: 75
envs:
- key: NODE_ENV
scope: RUN_TIME
value: production
- key: DATABASE_URL
scope: RUN_TIME
type: SECRET
- key: JWT_SECRET
scope: RUN_TIME
type: SECRET
- key: OPENAI_API_KEY
scope: RUN_TIME
type: SECRET
- key: SESSION_SECRET
scope: RUN_TIME
type: SECRET
- key: LOG_LEVEL
scope: RUN_TIME
value: info
databases:
- name: db
engine: PG
production: true
cluster_name: myapp-db
db_name: myapp
db_user: myapp
version: "16"
And here is the corresponding Express.js application (app.js) that works with this spec:
var express = require('express');
var path = require('path');
var { Pool } = require('pg');
var app = express();
var PORT = process.env.PORT || 8080;
// Database connection
var pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false },
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
// Middleware
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));
app.use('/static', express.static(path.join(__dirname, 'static'), {
maxAge: '7d'
}));
// Request logging
app.use(function(req, res, next) {
var start = Date.now();
res.on('finish', function() {
console.log(JSON.stringify({
method: req.method,
path: req.originalUrl,
status: res.statusCode,
duration: (Date.now() - start) + 'ms'
}));
});
next();
});
// Health check endpoint
app.get('/healthz', function(req, res) {
pool.query('SELECT 1')
.then(function() {
res.status(200).json({
status: 'healthy',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
})
.catch(function(err) {
res.status(503).json({
status: 'unhealthy',
error: err.message
});
});
});
// API routes
app.get('/api/items', function(req, res) {
var page = parseInt(req.query.page) || 1;
var limit = parseInt(req.query.limit) || 20;
var offset = (page - 1) * limit;
pool.query('SELECT * FROM items ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, offset])
.then(function(result) {
res.json({
items: result.rows,
page: page,
limit: limit,
total: result.rowCount
});
})
.catch(function(err) {
console.error('Query failed:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
});
app.post('/api/items', function(req, res) {
var name = req.body.name;
var description = req.body.description;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
pool.query(
'INSERT INTO items (name, description) VALUES ($1, $2) RETURNING *',
[name, description]
).then(function(result) {
res.status(201).json(result.rows[0]);
}).catch(function(err) {
console.error('Insert failed:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
});
// 404 handler
app.use(function(req, res) {
res.status(404).json({ error: 'Not found' });
});
// Error handler
app.use(function(err, req, res, next) {
console.error('Unhandled error:', err.stack);
res.status(500).json({ error: 'Internal server error' });
});
// Start server
app.listen(PORT, function() {
console.log('Server listening on port ' + PORT);
console.log('Environment: ' + (process.env.NODE_ENV || 'development'));
});
And the package.json:
{
"name": "myapp",
"version": "1.0.0",
"description": "Express.js API deployed on DigitalOcean App Platform",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"express": "^4.21.0",
"pg": "^8.13.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
Deploy it:
# Push to GitHub
git add .
git commit -m "production deployment"
git push origin main
# Verify deployment status
doctl apps list --format ID,DefaultIngress,ActiveDeployment.Phase
# Watch the deployment
doctl apps logs <app-id> --type build --follow
Common Issues and Troubleshooting
1. Build Fails: "npm ERR! Cannot read properties of null"
[build] npm ERR! Cannot read properties of null (reading 'matches')
[build] npm ERR! code ERR_INVALID_PACKAGE_TARGET
Cause: Your package-lock.json is out of sync with package.json. This happens when you add packages without committing the updated lock file, or when different team members use different npm versions.
Fix: Delete package-lock.json, run npm install locally to regenerate it, and commit both files:
rm package-lock.json
npm install
git add package.json package-lock.json
git commit -m "regenerate lock file"
git push
2. Deploy Succeeds but App Returns 502 Bad Gateway
HTTP/1.1 502 Bad Gateway
{"id":"service_unavailable","message":"Service Unavailable","request_id":"abc123"}
Cause: Your Express app is not listening on the port specified in http_port, or it crashes immediately after starting. The load balancer routes traffic but nothing is listening.
Fix: Verify your app listens on process.env.PORT and that http_port in the app spec matches. Check the runtime logs for crash messages:
doctl apps logs <app-id> --type run
Common subcauses:
- Missing environment variables that cause the app to crash on startup
- Database connection timeouts because the managed database has not finished provisioning
- A missing dependency that was in
devDependenciesinstead ofdependencies
3. Health Check Failing: Container Keeps Restarting
[deploy] Health check failed: GET /healthz returned status 503
[deploy] Restarting container (attempt 3/3)
[deploy] ERROR: Deployment failed - all health checks timed out
Cause: Your health check endpoint is returning a non-200 status, or the app takes longer to start than initial_delay_seconds allows.
Fix: Increase initial_delay_seconds in your health check configuration. If your app connects to a database on startup, it might take 15-30 seconds before it is ready to serve requests. Also make sure your health check endpoint does not depend on services that might be slow to initialize:
health_check:
http_path: /healthz
initial_delay_seconds: 30 # Give the app time to start
period_seconds: 30
timeout_seconds: 10 # Allow more time for the check itself
failure_threshold: 5 # Be more tolerant of transient failures
4. Database Connection Refused After Deploy
Error: connect ECONNREFUSED 127.0.0.1:5432
at TCPConnectWrap.afterConnect [as oncomplete]
Cause: You are trying to connect to localhost or 127.0.0.1 instead of using the connection string provided by App Platform. The database does not run on the same machine as your app.
Fix: Use the DATABASE_URL environment variable that App Platform injects, not a hardcoded local connection string. Make sure your code falls back correctly:
var pool = new Pool({
connectionString: process.env.DATABASE_URL // Never hardcode this
});
If DATABASE_URL is not being set, check that your database component is correctly linked in the app spec and that the database has finished provisioning (it can take 5-10 minutes for a new cluster).
5. Static Files Return 404 After Deployment
GET /static/css/styles.css 404 (Not Found)
GET /static/scripts/app.js 404 (Not Found)
Cause: Your .gitignore or .slugignore excludes the static directory, or the source_dir in your app spec does not include the static files directory.
Fix: Check your .gitignore and make sure the static files are committed to your repository. Verify them:
git ls-files static/
If empty, your static directory is being ignored. Also verify that express.static points to the correct absolute path using path.join(__dirname, 'static') rather than a relative path.
Best Practices
Always define
engines.nodein yourpackage.json. App Platform uses this to select the Node.js version. Without it, you get whatever the default is, which may not match your local environment. Be explicit:"engines": { "node": "20.x" }.Use
npm ciinstead ofnpm installin your build command.npm ciinstalls from the lock file exactly, is faster, and fails ifpackage-lock.jsonis out of sync. Setbuild_command: npm ci --productionin your app spec.Never commit secrets to your app spec. Use
type: SECRETfor sensitive values and set them through the dashboard or CLI. Your.do/app.yamlshould be safe to commit to a public repository.Set explicit
run_commandin your app spec. Do not rely on App Platform guessing your start script. Explicit is better than implicit.run_command: node app.jsleaves no ambiguity.Implement graceful shutdown. When App Platform deploys a new version, it sends
SIGTERMto your running containers. Handle it:
process.on('SIGTERM', function() {
console.log('SIGTERM received. Shutting down gracefully...');
server.close(function() {
pool.end(function() {
console.log('Connections closed. Exiting.');
process.exit(0);
});
});
// Force exit after 30 seconds
setTimeout(function() {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
});
Pin your dependencies. Use exact versions in
package.json(remove the^prefix) or rely onpackage-lock.jsonto pin them. A floating dependency that works locally but breaks in the cloud build is a frustrating debugging session.Add a
.slugignorefile to exclude files from the build that are not needed at runtime. This speeds up builds and reduces your slug size:
.git
tests/
docs/
*.test.js
*.spec.js
.env.example
README.md
Monitor your deployments. Set up DigitalOcean alerts for deployment failures. You can also use the API or webhooks to integrate with Slack or other notification systems.
Use the
doctlCLI for repeatable operations. The web console is fine for exploration, but for anything you do regularly -- deploying, checking logs, updating specs -- script it withdoctl. It is faster, less error-prone, and can be integrated into your CI/CD pipeline.
