Deploying Strapi v5 to DigitalOcean: Docker Compose in Action
Part 2 of "Building a Complete Deployment Environment for Strapi v5: A Practical Series"

Series Navigation:
Part 0: Introduction - Why This Setup?
Part 1: Containerizing Strapi v5
Part 2: Deploying to DigitalOcean (You are here)
Part 3: Production Web Server Setup (Coming next week)
Part 4: Automated Database Backups
Part 5: CI/CD Pipeline with GitHub Actions
New to the series? Each article works standalone, but I recommend reading Part 1 first since we'll be deploying the container we built there.
Alright, we've got our containerized Strapi app sitting in GitHub Container Registry. Now it's time to actually deploy this thing.
In this article, we're taking that Docker image and spinning it up on a DigitalOcean droplet. We'll use Docker Compose to manage both Strapi and PostgreSQL, set up proper user permissions, and get everything accessible via IP address. No domain setup yet, that's coming in Part 3. For now, we just want to see our app running on a real server.
Here's what I found when I was doing this: most deployment tutorials either skip important security steps or make things way more complicated than needed. We're going to keep it simple but do it right. That means creating a proper deploy user, setting up a firewall, and following basic security practices that'll save you headaches later.
Let's get into it.
What We're Building
By the end of this article, you'll have:
A DigitalOcean droplet running Ubuntu 22.04
Docker and Docker Compose installed
A dedicated deploy user (not running as root)
Strapi and PostgreSQL running in containers
Your app accessible via IP address
Basic firewall configured
All of this costs $6/month. Yeah, seriously.
Prerequisites
Before we start, make sure you've got:
Part 1 completed (your Strapi image in GitHub Container Registry)
A DigitalOcean account with payment method added
GitHub account access (we'll create a read-only token in Step 6 if needed)
SSH client on your machine (Terminal on Mac/Linux, PowerShell on Windows)
About 30-45 minutes
Don't have a DigitalOcean account? Sign up at digitalocean.com. They often have promo credits for new users. You can also use Hetzner, Vultr, or Linode - the steps are basically identical for any VPS provider.
New to SSH? If you've never used SSH before, don't worry - it's pretty straightforward. There are tons of beginner-friendly tutorials on YouTube and Google that'll get you up to speed in 10-15 minutes. It's a fundamental skill worth learning for any server work.
Step 1: Create Your DigitalOcean Droplet
I'm keeping this section brief because there are tons of tutorials out there on creating droplets. The DigitalOcean interface is pretty straightforward anyway.
Quick setup:
Login to DigitalOcean → Create → Droplets
Choose a datacenter region: Pick one close to your users
Choose an image: Ubuntu 22.04 (LTS) x64 or any latest LTS version
Choose a plan: Basic → Regular → $6/month (1GB RAM, 25GB SSD)
Authentication: SSH key (recommended) or password
Hostname: Something descriptive like
strapi-stagingClick Create Droplet
Wait about 60 seconds for it to spin up. You'll get an IP address, save this, you'll need it constantly.
If you've never created a droplet before, DigitalOcean has great documentation. Just search "how to create a droplet" on their site.
Step 2: Initial Server Setup
Now let's get the server ready for deployment. We'll do this properly - with a dedicated deploy user and basic security.
Connect to Your Droplet
# Replace YOUR_DROPLET_IP with your actual IP
ssh root@YOUR_DROPLET_IP
If you used an SSH key, you should be in. If you used a password, enter it when prompted.
Update System Packages
First thing: update everything.
apt update && apt upgrade -y
This takes a minute or two. Let it finish.
After the upgrade completes, it's a good idea to reboot the server - especially if kernel updates were installed:
reboot
Wait about 30 seconds, then reconnect:
ssh root@YOUR_DROPLET_IP
Install Docker
We're using Docker's official install script. It's the easiest way and handles all the dependencies:
# Download and run Docker's install script
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# Verify installation
docker --version
docker compose version
You'll see a message about running Docker as a non-privileged user, that's totally normal. We'll handle that in the next step by creating a dedicated deploy user and adding them to the docker group.
You should see something like:
Docker version 28.0.x (or later)
Docker Compose version v2.x.x
Note: Modern Docker includes Docker Compose v2 by default. If you see "docker-compose" (with a hyphen) in old tutorials, that's the deprecated v1. We're using docker compose (space, no hyphen).
Create Deploy User (Security Best Practice)
Here's where we do things right. Running everything as root is a bad idea and one wrong command and you could nuke your entire server. Let's create a dedicated deploy user:
# Create the user (you'll be prompted to set a password)
adduser deploy
# Add to sudo group (can run admin commands)
usermod -aG sudo deploy
# Add to docker group (can run docker commands)
usermod -aG docker deploy
Why we're doing this:
Limits damage if something goes wrong
Industry standard for any production-ish environment
Makes it clear which actions are application-related vs system-related
DevOps folks won't roast us in the comments 😅
Test the deploy user:
# Switch to deploy user
su - deploy
# Test docker access
docker ps
# If this works, you're golden
exit
You should see an empty container list, not a permission error. If you get a permission error, the usermod command didn't work - try logging out and back in.
Setup Firewall
Let's lock down the server properly:
# Still as root, enable UFW firewall
ufw allow ssh
ufw allow 1337/tcp # Strapi default port
ufw allow 5432/tcp # PostgreSQL (for database clients like DBeaver)
ufw --force enable
ufw status
You should see:
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
1337/tcp ALLOW Anywhere
5432/tcp ALLOW Anywhere
About port 5432: Yeah, we're exposing PostgreSQL publicly. For a staging environment, this is totally fine - it lets you connect with DBeaver, TablePlus, or any database client to inspect your data during development.
Is this a security risk? Yes, but it's manageable for staging. Without valid database credentials (username/password), attackers can't access your data. The main risks are brute-force password attacks and potential DoS attacks, so make sure you use strong database passwords. We'll lock this down properly when we get to production setup in a future article.
Step 3: Prepare Application Directory
Now let's set up where our application will live:
# Still as root
mkdir -p /opt/strapi-backend
chown -R deploy:deploy /opt/strapi-backend
chmod 755 /opt/strapi-backend
Why /opt/strapi-backend?
If you've been around Linux deployments, you've probably seen the age-old debate: /var/www vs /opt vs /srv - everyone has opinions on this one.
Here's my thinking: /opt traditionally houses optional or third-party software packages, which feels like a good fit for our containerized Strapi setup. It's where the Filesystem Hierarchy Standard suggests putting complete application stacks like ours. Plus, /var/www is more commonly used for traditional web servers serving static files directly.
That said, this isn't a hill I'm dying on. If you prefer /var/www or /srv or even /home/deploy/apps, go for it! The important thing is keeping your deployment organized and consistent. Just update the paths throughout this guide to match whatever you choose.
Quick command breakdown:
chown -R deploy:deploy- Changes ownership of the directory to the deploy userchmod 755- Sets permissions: owner can read/write/execute, others can only read/execute
New to these Linux commands? They're fundamental for server management, a quick Google search for "chown chmod explained" will get you up to speed in 5 minutes.
Now switch to the deploy user for all application work:
# Switch to deploy user
su - deploy
# Navigate to app directory
cd /opt/strapi-backend
From here on, everything runs as the deploy user. No more root commands unless we're installing system-level stuff.
Step 4: Create Docker Compose Configuration
If you've been developing locally, you probably already have a docker-compose.stg.yml file. If not, you'll want to create one in your project, here's the configuration you'll need:
version: '3'
services:
strapi-backend:
container_name: strapi-backend
image: ghcr.io/YOUR_GITHUB_USERNAME/your-repo-name:v1.0.0
restart: unless-stopped
env_file: .env.stg
environment:
NODE_ENV: production
DATABASE_CLIENT: ${DATABASE_CLIENT}
DATABASE_HOST: strapi-db
DATABASE_PORT: ${DATABASE_PORT}
DATABASE_NAME: ${DATABASE_NAME}
DATABASE_USERNAME: ${DATABASE_USERNAME}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
APP_KEYS: ${APP_KEYS}
ports:
- "1337:1337"
networks:
- strapi-network
depends_on:
- strapi-db
strapi-db:
container_name: strapi-db
image: postgres:16-alpine
restart: unless-stopped
env_file: .env.stg
environment:
POSTGRES_USER: ${DATABASE_USERNAME}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
volumes:
- strapi-data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- strapi-network
volumes:
strapi-data:
networks:
strapi-network:
name: Strapi-Network
driver: bridge
Important: Replace ghcr.io/YOUR_GITHUB_USERNAME/your-repo-name:v1.0.0 with your actual image URL from Part 1.
The easiest way to get this file on your server is to transfer it directly from your local machine:
# From your local machine (not on the server)
# Replace /path/to/your/project with your actual project path
scp /path/to/your/project/docker-compose.stg.yml root@YOUR_DROPLET_IP:/opt/strapi-backend/
# Example:
# scp /Users/john/Projects/strapi-backend/docker-compose.stg.yml root@167.99.234.123:/opt/strapi-backend/
If you need to make any changes to the transferred file (like updating the image version), you can edit it on the server using this command:
# SSH into your server first, then:
nano docker-compose.stg.yml
Or if you wanna just create the file directly, you can use the same command above.
Quick note: I'm using nano for these examples because it's beginner-friendly and shows the keyboard shortcuts at the bottom. If you're a vim or emacs user, feel free to use your preferred editor.
Understanding the Configuration
Let's break down what's happening here:
The Strapi Service:
restart: unless-stopped- Automatically restarts if it crashesenv_file: .env.stg- Loads environment variables from fileDATABASE_HOST: strapi-db- Connects to our PostgreSQL servicedepends_on: strapi-db- Waits for database before starting
The PostgreSQL Service:
postgres:16-alpine- Using PostgreSQL 16 on Alpine Linux (smaller, faster)volumes: strapi-data- Persists database data even when container stopsports: "5432:5432"- Exposed so you can connect with database clients
Networking:
Both services connect via
strapi-networkContainers can talk to each other by service name
Strapi finds PostgreSQL at hostname
strapi-db
Step 5: Create Environment File
If you've been developing locally, you already have a .env file with all your Strapi secrets and database credentials. If not, you'll need to create one - here's the template:
# Database
DATABASE_CLIENT=postgres
DATABASE_HOST=strapi-db
DATABASE_PORT=5432
DATABASE_NAME=strapi_staging
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=your_secure_password_here
# Strapi
HOST=0.0.0.0
PORT=1337
APP_KEYS=your-app-keys-from-local-env
API_TOKEN_SALT=your-api-token-salt
ADMIN_JWT_SECRET=your-admin-jwt-secret
JWT_SECRET=your-jwt-secret
TRANSFER_TOKEN_SALT=your-transfer-token-salt
The easiest way to get this file on your server is to transfer it directly from your local machine:
# From your local machine (not on the server)
scp /path/to/your/project/.env.stg root@YOUR_DROPLET_IP:/opt/strapi-backend/
If you need to make any changes to the transferred file (like updating credentials), you can edit it on the server using the same command: nano .env.stg
Step 6: Login to GitHub Container Registry
Your server needs permission to pull your image from GHCR.
About GitHub Token Permissions:
If your package is public, you can actually skip this login step entirely, no authentication needed!
If your package is private, you'll need to login. Here's the thing: in Part 1, we created a token with write:packages permission for pushing images. For deployment, we only need read:packages permission. It's a good security practice to create a separate read-only token for production/staging deployments.
To create a read-only token:
GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
Generate new token with only
read:packagesscopeUse this token on your server
Login command:
# Set your GitHub token (replace with your actual token)
export GITHUB_TOKEN=your_github_token_here
# Login to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
You should see: Login Succeeded
Step 7: Deploy the Stack
This is the moment of truth. Let's fire up both services:
cd /opt/strapi-backend
# Start everything in detached mode (runs in background)
docker compose -f docker-compose.stg.yml --env-file .env.stg up -d
You'll see Docker pull the images and start the containers:
[+] Running 3/3
✔ Network strapi-network Created
✔ Container strapi-db Started
✔ Container strapi-backend Started
First time setup takes a bit longer because:
Docker pulls the PostgreSQL image (~50MB)
Docker pulls your Strapi image (~500-700MB)
PostgreSQL initializes the database
Strapi runs migrations and sets up tables
Give it about 60 seconds to fully start up.
Step 8: Verify Everything Works
Let's check if our services are running:
# Check container status
docker compose -f docker-compose.stg.yml ps
You should see:
NAME IMAGE STATUS
strapi-backend ghcr.io/you/your-repo:v1.0.0 Up
strapi-db postgres:16-alpine Up
Both should show "Up" status. If you see "Exit" or "Restarting", something went wrong.
Check the logs:
# View all logs
docker compose -f docker-compose.stg.yml logs
# Follow logs in real-time
docker compose -f docker-compose.stg.yml logs -f
# Check just Strapi logs
docker compose -f docker-compose.stg.yml logs strapi-backend
Look for these good signs:
Server started on port 1337
Database connection established
Test from the server:
# Wait a bit for startup
sleep 30
# Test if Strapi responds
curl -f http://localhost:1337/admin || echo "Not ready yet"
If you get HTML back, Strapi is running! If you get "Not ready yet", give it another 30 seconds and try again.
Test from your browser:
Open: http://YOUR_DROPLET_IP:1337/admin
You should see the Strapi admin setup page! 🎉
If you see "This site can't be reached", check:
Firewall is allowing port 1337
Container is actually running
You're using the right IP address
Step 9: Create Your Admin Account
Since this is a fresh Strapi installation, you'll need to create the admin account:
Go to
http://YOUR_DROPLET_IP:1337/adminFill in your admin details
Click "Let's start"
Don't use weak passwords here. Even though this is staging, it's exposed to the internet. Use something strong.
Step 10: Connect with a Database Client (Optional)
Since we exposed port 5432, you can now connect with DBeaver or any PostgreSQL client:
Connection details:
Host: YOUR_DROPLET_IP
Port: 5432
Database: strapi_staging (or whatever you set)
Username: strapi (or whatever you set)
Password: Your database password
This is super useful for debugging or running SQL queries during development.
Understanding What We Built
Let's recap what's actually running on your server:
The Stack:
Ubuntu 22.04 operating system
Docker managing containers
PostgreSQL storing all your Strapi data
Strapi serving the admin panel and API
Docker network connecting everything
Data Persistence:
PostgreSQL data lives in a Docker volume (
strapi-data)Survives container restarts and updates
Won't disappear unless you explicitly remove volumes
Security Setup:
Dedicated deploy user (not running as root)
Firewall allowing only necessary ports
Containers run with restart policies
What's Still Missing:
Custom domain (coming in Part 3)
SSL certificate (coming in Part 3)
Automated backups (coming in Part 4)
CI/CD pipeline (coming in Part 5)
But what we have now is solid. Your Strapi backend is running on a real server, accessible via IP, with a proper database setup. That's the foundation everything else builds on.
What's Next?
We've got Strapi running, but accessing it via http://YOUR_IP:1337 isn't great for the long term. In Part 3, we're setting up:
Nginx as a reverse proxy
Custom domain setup
Free SSL certificate with Let's Encrypt
Proper security headers
Access via
https://api.yourdomain.com
That'll make this feel like a real production environment.
The deployment work we did today is the foundation. Everything else builds on this Docker Compose setup.
Quick Reference
Deploy the stack:
docker compose -f docker-compose.stg.yml --env-file .env.stg up -d
View logs:
docker compose -f docker-compose.stg.yml logs -f
Restart services:
docker compose -f docker-compose.stg.yml restart
Stop everything:
docker compose -f docker-compose.stg.yml down
Check status:
docker compose -f docker-compose.stg.yml ps
Got stuck during deployment? Drop a comment with the error message and I'll help you troubleshoot. Next week, we're making this setup production-ready with Nginx and SSL - see you then!






