Skip to main content

Command Palette

Search for a command to run...

Deploying Strapi v5 to DigitalOcean: Docker Compose in Action

Part 2 of "Building a Complete Deployment Environment for Strapi v5: A Practical Series"

Updated
13 min read
Deploying Strapi v5 to DigitalOcean: Docker Compose in Action

Series Navigation:

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:

  1. Login to DigitalOcean → CreateDroplets

  2. Choose a datacenter region: Pick one close to your users

  3. Choose an image: Ubuntu 22.04 (LTS) x64 or any latest LTS version

  4. Choose a plan: Basic → Regular → $6/month (1GB RAM, 25GB SSD)

  5. Authentication: SSH key (recommended) or password

  6. Hostname: Something descriptive like strapi-staging

  7. Click 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 user

  • chmod 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 crashes

  • env_file: .env.stg - Loads environment variables from file

  • DATABASE_HOST: strapi-db - Connects to our PostgreSQL service

  • depends_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 stops

  • ports: "5432:5432" - Exposed so you can connect with database clients

Networking:

  • Both services connect via strapi-network

  • Containers 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:

  1. GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)

  2. Generate new token with only read:packages scope

  3. Use 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:

  1. Docker pulls the PostgreSQL image (~50MB)

  2. Docker pulls your Strapi image (~500-700MB)

  3. PostgreSQL initializes the database

  4. 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:

  1. Go to http://YOUR_DROPLET_IP:1337/admin

  2. Fill in your admin details

  3. 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!

Building a Complete Deployment Environment for Strapi v5: A Practical Series

Part 5 of 7

Learn how to build a production-ready, budget-friendly staging environment for Strapi v5 using Docker, DigitalOcean, and modern DevOps practices. Complete with automated backups and CI/CD.

Up next

Containerizing Strapi v5 for Production: The Right Way

Part 1 of "Building a Complete Deployment Environment for Strapi v5: A Practical Series"