# Deploying Strapi v5 to DigitalOcean: Docker Compose in Action

> **Series Navigation:**
> 
> * **Part 0**: [Introduction - Why This Setup?](https://devnotes.kamalthennakoon.com/from-local-to-live-your-strapi-deployment-roadmap)
>     
> * **Part 1**: [Containerizing Strapi v5](https://devnotes.kamalthennakoon.com/containerizing-strapi-v5-for-production-the-right-way)
>     
> * **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:**

1. Login to DigitalOcean → **Create** → **Droplets**
    
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**

```bash
# 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.

```bash
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:

```bash
reboot
```

Wait about 30 seconds, then reconnect:

```bash
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:

```bash
# 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:

```plaintext
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:

```bash
# 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:**

```bash
# 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:

```bash
# 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:

```plaintext
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:

```bash
# 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:

```bash
# 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:

```yaml
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:

```bash
# 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:*

```bash
# 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:

```bash
# 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:

```bash
# 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:**

```bash
# 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:

```bash
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:

```plaintext
[+] 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:

```bash
# Check container status
docker compose -f docker-compose.stg.yml ps
```

You should see:

```plaintext
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:**

```bash
# 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:

```plaintext
Server started on port 1337
Database connection established
```

**Test from the server:**

```bash
# 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:**

```bash
docker compose -f docker-compose.stg.yml --env-file .env.stg up -d
```

**View logs:**

```bash
docker compose -f docker-compose.stg.yml logs -f
```

**Restart services:**

```bash
docker compose -f docker-compose.stg.yml restart
```

**Stop everything:**

```bash
docker compose -f docker-compose.stg.yml down
```

**Check status:**

```bash
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!*
