Skip to Content

Production Guide: Deploy OpenProject with Docker Compose + Caddy + PostgreSQL on Ubuntu

A production-ready OpenProject deployment with HTTPS, hardened config, backup automation, verification, and practical troubleshooting.

Introduction: real-world use case

Many teams adopt project management software quickly, but run into operational limits once usage spreads beyond a single department. Product managers need clear roadmaps, engineering needs issue-level traceability, operations needs uptime and backup guarantees, and leadership needs an auditable history of changes. OpenProject is a strong open-source option for this stage because it combines classic project planning with agile boards, work packages, role-based permissions, and enterprise-friendly governance.

This guide walks through a production deployment of OpenProject on Ubuntu using Docker Compose + Caddy + PostgreSQL. The goal is not just to “make it run,” but to make it reliable under normal operational pressure: secure secret handling, HTTPS by default, persistent data, backup procedures, verification checks, and practical troubleshooting steps your team can execute during incidents.

If you are currently running project tracking in spreadsheets, disconnected SaaS tools, or a single VM without backup discipline, this setup gives you a clear path to a hardened self-hosted platform. We will keep the guide implementation-focused, with explicit commands, expected outcomes, and checkpoints between steps so you can safely move from a blank server to a production-ready instance.

Architecture and flow overview

This deployment uses a small but robust architecture:

  • Caddy terminates HTTPS and handles automatic certificate management.
  • OpenProject web container serves the UI and API.
  • OpenProject worker container handles background jobs and asynchronous tasks.
  • PostgreSQL container stores relational data (projects, users, work packages, workflows).
  • Persistent Docker volumes keep data and uploaded assets safe across container restarts.

Request flow is straightforward: users access https://projects.yourdomain.com → Caddy proxies requests to OpenProject web service on the internal Docker network → OpenProject reads/writes to PostgreSQL. Background actions (notifications, scheduled jobs, data processing) are executed by the worker service, keeping interactive UI requests responsive.

Operationally, this split improves stability: if heavy background processing occurs, the web process remains available. It also makes troubleshooting cleaner because logs and failure domains are separated by role.

Prerequisites

  • Ubuntu 22.04/24.04 server with at least 4 vCPU, 8 GB RAM, and 60+ GB SSD.
  • A fully qualified domain (example: projects.example.com) pointing to the server IP.
  • Ports 80/tcp and 443/tcp open in cloud firewall and host firewall.
  • A non-root sudo user for operations.
  • Docker Engine + Docker Compose plugin installed.
sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER

Manual copy fallback: If the copy button does not work in your browser/editor, select the command block and copy it manually.

Step-by-step deployment

1) Create project layout and environment file

We separate configuration, reverse proxy config, and backups so operations remain predictable during audits and incident response.

mkdir -p ~/openproject/{caddy,postgres,backups}
cd ~/openproject
touch .env
chmod 600 .env

Manual copy fallback: If copy is unavailable, manually copy and paste this block.

Edit .env with strong generated secrets (never reuse passwords from other systems):

OP_DOMAIN=projects.example.com
OP_DB_NAME=openproject
OP_DB_USER=openproject
OP_DB_PASSWORD=CHANGE_ME_DB_PASSWORD
OP_SECRET_KEY_BASE=CHANGE_ME_LONG_RANDOM_STRING
OP_HOST_NAME=projects.example.com
TZ=UTC

Manual copy fallback: Copy this env template manually if script-based copy is blocked.

2) Write Docker Compose for OpenProject + PostgreSQL

This Compose file uses pinned major versions and dedicated healthchecks. It is intentionally explicit to reduce hidden behavior in production.

services:
  postgres:
    image: postgres:16
    container_name: op-postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${OP_DB_NAME}
      POSTGRES_USER: ${OP_DB_USER}
      POSTGRES_PASSWORD: ${OP_DB_PASSWORD}
      TZ: ${TZ}
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${OP_DB_USER} -d ${OP_DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 10

  openproject-web:
    image: openproject/openproject:15
    container_name: op-web
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      OPENPROJECT_HTTPS: "false"
      OPENPROJECT_HOST__NAME: ${OP_HOST_NAME}
      DATABASE_URL: postgres://${OP_DB_USER}:${OP_DB_PASSWORD}@postgres:5432/${OP_DB_NAME}
      SECRET_KEY_BASE: ${OP_SECRET_KEY_BASE}
      RAILS_MIN_THREADS: "4"
      RAILS_MAX_THREADS: "16"
    volumes:
      - openproject_assets:/var/openproject/assets
    expose:
      - "8080"

  openproject-worker:
    image: openproject/openproject:15
    container_name: op-worker
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      OPENPROJECT_HTTPS: "false"
      OPENPROJECT_HOST__NAME: ${OP_HOST_NAME}
      DATABASE_URL: postgres://${OP_DB_USER}:${OP_DB_PASSWORD}@postgres:5432/${OP_DB_NAME}
      SECRET_KEY_BASE: ${OP_SECRET_KEY_BASE}
      OPENPROJECT_WEB_CONCURRENCY: "0"
    command: ./docker/prod/worker
    volumes:
      - openproject_assets:/var/openproject/assets

  caddy:
    image: caddy:2
    container_name: op-caddy
    restart: unless-stopped
    depends_on:
      - openproject-web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  openproject_assets:
  caddy_data:
  caddy_config:

Manual copy fallback: If one-click copy fails, select all YAML text and copy manually.

3) Configure Caddy reverse proxy

Caddy simplifies TLS operations and renewals. Keep proxy headers explicit for clean upstream behavior.

cat > caddy/Caddyfile <<'EOF'
{$OP_DOMAIN} {
  encode zstd gzip
  reverse_proxy openproject-web:8080 {
    header_up X-Forwarded-Proto {scheme}
    header_up X-Forwarded-Host {host}
    header_up X-Real-IP {remote_host}
  }
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "SAMEORIGIN"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}
EOF

Manual copy fallback: If copy control is unavailable, manually copy the Caddyfile block.

4) Launch stack and initialize OpenProject

Bring services up, then run the OpenProject migrations and initial setup tasks. Perform these once per fresh deployment.

docker compose pull
docker compose up -d
docker compose exec openproject-web ./docker/prod/seeder
docker compose logs --tail=200 openproject-web

Manual copy fallback: Manually select and copy these startup commands if needed.

5) Configure host firewall and baseline hardening

Do not expose unnecessary ports from the host. Only keep SSH, HTTP, and HTTPS reachable.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Manual copy fallback: Use manual copy if the button is stripped by platform sanitization.

6) Add automated database backup and retention

A production deployment is incomplete without tested backups. This script keeps compressed PostgreSQL dumps with 14-day retention.

cat > backups/pg_backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /home/$USER/openproject
source .env
TS=$(date +%F_%H-%M-%S)
OUT="backups/openproject_${TS}.sql.gz"
docker compose exec -T postgres pg_dump -U "$OP_DB_USER" "$OP_DB_NAME" | gzip > "$OUT"
find backups -name 'openproject_*.sql.gz' -type f -mtime +14 -delete
echo "backup_created=$OUT"
EOF
chmod +x backups/pg_backup.sh
(crontab -l 2>/dev/null; echo "15 2 * * * /home/$USER/openproject/backups/pg_backup.sh >> /home/$USER/openproject/backups/backup.log 2>&1") | crontab -

Manual copy fallback: If copy fails, manually copy and save the backup script and cron line.

Configuration and secrets handling best practices

Treat OpenProject as a business-critical system because it carries delivery timelines, internal discussions, ownership decisions, and sometimes customer-impacting change records. Start by moving secrets out of git entirely. Keep .env readable only by the deployment user, rotate credentials periodically, and use a secret manager when your environment matures.

Use unique credentials per environment (dev/stage/prod). Never share a single database password across stacks. Keep your TLS endpoint and app host names stable so webhook integrations and email links don’t break after upgrades. For upgrades, test in staging first, then schedule maintenance windows with rollback checkpoints (database backup + tested restore path).

Finally, define minimum role boundaries inside OpenProject: administrators for platform governance, project managers for workflow control, and members for execution access. Over-permissive defaults create silent risk that only appears during audits or accidental destructive actions.

Verification checklist

  • docker compose ps shows all services healthy and running.
  • Opening https://projects.example.com loads valid HTTPS with no browser warnings.
  • Admin login works and project creation succeeds.
  • Creating/editing work packages persists after container restart.
  • Background jobs execute (notifications, reminders, async updates).
  • Backup script creates a compressed dump, and test restore is documented.
docker compose ps
curl -I https://projects.example.com
docker compose logs --tail=100 op-caddy
docker compose logs --tail=100 openproject-web
docker compose logs --tail=100 openproject-worker

Manual copy fallback: If copy is disabled, manually copy commands and run them in sequence.

Common issues and fixes

Issue 1: TLS certificate is not issued

Usually DNS or firewall related. Confirm A/AAAA records resolve to the server and port 80 is reachable publicly for ACME validation. Check Caddy logs for challenge failures and retry after DNS propagation.

Issue 2: OpenProject web stays in restart loop

Most often caused by invalid DATABASE_URL or missing/changed SECRET_KEY_BASE. Verify env variables, then restart only affected services. Keep secret key stable across restarts.

Issue 3: Worker backlog grows and notifications lag

Scale worker capacity by allocating more CPU/RAM and validating database performance. In high-volume environments, tune Postgres memory and connection settings gradually while monitoring latency.

Issue 4: Slow page loads under team peak hours

Check host saturation first (CPU steal, memory pressure, IO wait). Then evaluate query-heavy project reports and custom fields. Add observability early to avoid blind scaling decisions.

Issue 5: Backup exists but restore fails during incident drill

This is common when teams backup without restore testing. Run quarterly restore rehearsals into a temporary isolated environment and validate login, project data integrity, and attachment availability.

FAQ

Can I run OpenProject without a reverse proxy in production?

Technically yes, but it is not recommended. A reverse proxy handles TLS lifecycle, secure headers, and request normalization more cleanly than exposing the app directly.

Is SQLite acceptable for small production teams?

For production, PostgreSQL is the better default. It offers stronger concurrency behavior, easier backup tooling, and better operational predictability as usage grows.

How often should I back up OpenProject data?

At minimum daily full database backups with 14–30 day retention. If your organization updates work packages constantly, move to more frequent snapshots and WAL-based strategies.

What is the safest way to upgrade OpenProject?

Pin images, clone staging from production backups, rehearse upgrade there, then perform production upgrade in a maintenance window with rollback criteria pre-defined.

Do I need separate web and worker services?

For production, yes. Separation protects user-facing responsiveness during asynchronous job spikes and gives cleaner observability for root-cause analysis.

How do I secure admin access?

Use strong unique credentials, enforce MFA where possible, restrict admin accounts to a small trusted group, and audit admin actions regularly.

Can I integrate OpenProject with SSO later?

Yes. Start with local auth for initial rollout, then add SSO/SAML/OIDC after baseline stability is achieved and identity mapping is tested in staging.

Related guides

Talk to us

Need help deploying OpenProject in production, designing secure delivery workflows, or building backup and upgrade runbooks your team can trust during incidents? We can help with architecture, hardening, migration, and operational readiness.

Contact Us

Production Guide: Deploy Directus with Docker Compose + Caddy + PostgreSQL on Ubuntu
A production-ready Directus deployment blueprint with hardened networking, secrets handling, backups, and operational verification.