Forum Platform
    Docker Launcher

    Deploy Discourse on a VPS

    Deploy Discourse on a RamNode KVM VPS the supported way — the official launcher container, Let's Encrypt TLS, external SMTP relay, backups, and a tuned memory profile.

    Discourse is the heaviest of the mainstream open-source forum platforms and the least forgiving about how you install it. It is a Ruby on Rails application backed by PostgreSQL and Redis, with a Sidekiq job queue and an Ember frontend. The Discourse team supports exactly one installation method: their Docker container, driven by the launcher script in /var/discourse. Anything else (bare-metal Ruby, cPanel, Plesk, a hand-rolled Compose file) is unsupported and will cost you time on every upgrade.

    This guide deploys Discourse on a single RamNode KVM VPS with Let's Encrypt TLS, an external SMTP relay, automated backups, and a tuned memory profile.


    1. Choose the right RamNode instance

    Discourse is memory-bound before it is CPU-bound. The container bootstrap alone compiles assets and will fail or thrash on an underprovisioned box.

    Community sizeRAMvCPUDiskNotes
    Testing / under 20 active users2 GB130 GBRequires swap. Bootstrap is slow but completes.
    Small production, under 200 active users4 GB250 GBRecommended floor for anything real.
    200 to 1000 active users8 GB480 GB+Room for plugins, uploads, and Sidekiq concurrency.

    Notes specific to RamNode:

    • Pick KVM, not a container plan. Discourse needs its own kernel-level Docker daemon. Do not attempt this on an OpenVZ-style container.
    • Pick a location near your users. RamNode has US and EU locations. Discourse is chatty over WebSockets, so latency shows up in the live-update experience.
    • Ubuntu 24.04 LTS x86_64 is the template to select. Discourse's install script targets Ubuntu LTS.
    • Budget disk for uploads and backups. Backups land on the same volume by default. A forum with heavy image uploads will outgrow a 30 GB plan faster than you expect.

    2. Prerequisites you must have before touching the server

    1. A domain or subdomain (for example forum.example.com) with an A record pointed at your RamNode IPv4. Add an AAAA record if you are using the IPv6 allocation. Let the DNS propagate fully before you run the installer, because Let's Encrypt validation happens during bootstrap.
    2. An external SMTP relay. This is not optional and it is the single most important RamNode-specific constraint in this guide. See the next section.
    3. SSH access as root or a sudo user.

    The SMTP situation on RamNode

    RamNode does not permit mail services on its VPS platform, and outbound port 25 is not available to you. Discourse cannot function without working email: account activation, password resets, digests, and notification replies all depend on it. Registration will silently stall at "check your inbox" if SMTP is broken.

    So you relay through a transactional email provider over port 587 (STARTTLS) or 465 (implicit TLS). Any of these work:

    • Postmark
    • SendGrid
    • Mailgun
    • Amazon SES
    • Brevo
    • Scaleway TEM

    Before you start the install, have these five values in hand:

    shell
    SMTP host        e.g. smtp.postmarkapp.com
    SMTP port        587
    SMTP username    provider-supplied
    SMTP password    provider-supplied
    Notification from address   e.g. forum@example.com

    Set up SPF, DKIM, and DMARC records for your sending domain at the provider before your first user registers. Discourse sends a lot of mail, and a cold domain with no DKIM will land in spam and generate support tickets you did not need to have.

    3. Base server preparation

    SSH in and bring the box current.

    shell
    apt update && apt upgrade -y

    If the kernel was updated, reboot before continuing.

    Set the hostname and timezone:

    shell
    hostnamectl set-hostname forum.example.com
    timedatectl set-timezone UTC

    Add swap

    Mandatory on 2 GB plans, recommended on 4 GB. Discourse's asset compilation step is the peak memory consumer and swap keeps the OOM killer away from it.

    shell
    fallocate -l 2G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile
    swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab
    sysctl -w vm.swappiness=10
    echo 'vm.swappiness=10' >> /etc/sysctl.d/99-discourse.conf

    Verify with free -h.

    Firewall

    Discourse's container binds 80 and 443 directly on the host. Nothing else needs to be exposed.

    shell
    apt install -y ufw
    ufw allow 22/tcp
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw --force enable
    ufw status verbose

    If you changed your SSH port, adjust before enabling. Locking yourself out of a RamNode VPS means a console session through the panel.

    Unattended security updates and fail2ban

    shell
    apt install -y unattended-upgrades fail2ban
    dpkg-reconfigure -plow unattended-upgrades
    systemctl enable --now fail2ban

    Note that unattended-upgrades patches the host. It does not update Discourse itself, which lives inside the container. That is a separate process covered in section 8.

    4. Install Discourse

    There are two paths. Both end in the same place.

    Path A: the official one-line installer (recommended)

    Discourse now ships a bootstrapping script that installs Docker and git if missing, clones the Docker repo, and launches the setup wizard:

    shell
    wget -qO- https://raw.githubusercontent.com/discourse/discourse_docker/main/install-discourse | sudo bash

    The installer auto-tunes UNICORN_WORKERS and db_shared_buffers based on detected RAM and CPU, which is a meaningful improvement over hand-editing those values.

    If you would rather read a script before piping it to bash (you should), download it first:

    shell
    wget https://raw.githubusercontent.com/discourse/discourse_docker/main/install-discourse
    less install-discourse
    sudo bash install-discourse

    Path B: manual clone and setup

    More transparent, and closer to what you will be doing on every subsequent maintenance task anyway.

    shell
    # Install Docker
    curl -fsSL https://get.docker.com | sh
    systemctl enable --now docker
    docker version
    
    # Clone the Discourse Docker repo
    git clone https://github.com/discourse/discourse_docker.git /var/discourse
    cd /var/discourse
    chmod 700 containers
    ./discourse-setup

    discourse-setup prompts for:

    shell
    Hostname for your Discourse?           forum.example.com
    Email address for admin account(s)?    you@example.com
    SMTP server address?                   smtp.postmarkapp.com
    SMTP port?                             587
    SMTP user name?                        <provider token>
    SMTP password?                         <provider token>
    notification email address?            forum@example.com
    Optional email address for Let's Encrypt warnings?   you@example.com

    Answer the Let's Encrypt prompt with a real address. Skipping it means you fall back to HTTP only and have to wire TLS in manually later.

    The script writes /var/discourse/containers/app.yml and then runs the bootstrap. Bootstrap takes anywhere from 3 minutes on a 4 GB / 4 vCPU plan to 15 minutes on a 2 GB single-core plan. It is compiling assets, so do not interrupt it.

    When it finishes:

    shell
    ./launcher start app

    Browse to https://forum.example.com. A 502 for the first minute or two is normal while Unicorn spins up. Register the admin account using the email you supplied.

    5. Understanding app.yml

    Everything about your instance lives in /var/discourse/containers/app.yml. Read it once, properly. The parts that matter:

    shell
    templates:
      - "templates/postgres.template.yml"
      - "templates/redis.template.yml"
      - "templates/web.template.yml"
      - "templates/web.ratelimited.template.yml"
      - "templates/web.ssl.template.yml"
      - "templates/web.letsencrypt.ssl.template.yml"
    
    expose:
      - "80:80"
      - "443:443"
    
    params:
      db_default_text_search_config: "pg_catalog.english"
      db_shared_buffers: "1024MB"
      UNICORN_WORKERS: 4
    
    env:
      LANG: en_US.UTF-8
      DISCOURSE_HOSTNAME: forum.example.com
      DISCOURSE_DEVELOPER_EMAILS: 'you@example.com'
      DISCOURSE_SMTP_ADDRESS: smtp.postmarkapp.com
      DISCOURSE_SMTP_PORT: 587
      DISCOURSE_SMTP_USER_NAME: <token>
      DISCOURSE_SMTP_PASSWORD: <token>
      DISCOURSE_SMTP_ENABLE_START_TLS: true
      DISCOURSE_NOTIFICATION_EMAIL: forum@example.com
      LETSENCRYPT_ACCOUNT_EMAIL: you@example.com

    Two rules to internalize:

    1. Every change to app.yml requires a rebuild, not a restart. ./launcher rebuild app tears the container down, rebuilds it, and brings it back. Expect several minutes of downtime.
    2. The two SSL templates must both be present and in that order, and web.letsencrypt.ssl.template.yml must come after web.ssl.template.yml. Getting this backwards produces certificate errors that are annoying to diagnose.

    Tuning by plan size

    The auto-tuner does a decent job, but if you are hand-editing:

    Plan RAMUNICORN_WORKERSdb_shared_buffers
    2 GB2128MB
    4 GB3 to 4512MB
    8 GB6 to 81024MB

    Each Unicorn worker costs roughly 250 to 400 MB resident once warm. Over-provisioning workers on a small RamNode plan is the number one cause of self-inflicted OOM kills.

    6. TLS certificates

    If you enabled the Let's Encrypt templates during setup, the container handles issuance and renewal internally via its own cron. You do not run certbot on the host, and you should not install nginx on the host either, because the container owns 80 and 443.

    Verify:

    shell
    cd /var/discourse
    ./launcher enter app
    ls -la /shared/letsencrypt/
    exit

    If issuance failed, the near-universal causes are:

    • DNS not propagated when bootstrap ran. Fix DNS, then ./launcher rebuild app.
    • ufw blocking 80. Let's Encrypt HTTP-01 validation needs port 80 reachable from the internet.
    • The A record points somewhere other than your RamNode IP.

    Behind Cloudflare

    If you front the forum with Cloudflare, set the proxy status to DNS-only during the initial bootstrap so HTTP-01 validation succeeds, then turn the proxy on afterward and set SSL mode to Full (strict). Leaving the orange cloud on during first bootstrap is a common failure.

    7. Post-install hardening and configuration

    Log in as admin and go through /admin:

    • Set force_https so all traffic is redirected. Under Settings, Security.
    • Enable 2FA for staff. Settings, Login, enforce_second_factor set to staff.
    • Turn off login_required or turn it on depending on whether you want a public or gated forum.
    • Review max_image_size_kb and max_attachment_size_kb against your RamNode disk allocation.
    • Set up the setup wizard at /wizard if you skipped it.

    Email deliverability check

    Discourse ships a diagnostic at /admin/email. Send a test message and check /admin/email/sent and /admin/logs/screened_emails. If mail is not moving:

    shell
    cd /var/discourse
    ./launcher enter app
    rails c
    # in the console:
    Rails.application.config.action_mailer.smtp_settings
    exit
    exit

    Also check Sidekiq at /sidekiq. Stuck jobs in the retry queue almost always mean SMTP credentials are wrong or the provider is rejecting your sending domain.

    MaxMind (optional)

    For geolocation features, register for a free MaxMind GeoLite2 key and add it to app.yml:

    shell
    env:
      DISCOURSE_MAXMIND_LICENSE_KEY: "your_key_here"

    Then rebuild.

    8. Backups

    Discourse has a competent built-in backup system. Use it, and then get the backups off the VPS.

    Configure in-app backups

    At /admin/backups, set:

    • backup_frequency to 1 (daily)
    • maximum_backups to something your disk can hold, typically 5 on a small plan
    • include_thumbnails_in_backups off if disk is tight

    Backups write to /var/discourse/shared/standalone/backups/default/.

    Ship them off-box

    An on-server backup is not a backup. RamNode plans are single-node, so a host failure takes your forum and your backups together. Push to object storage or a second box nightly:

    shell
    #!/bin/bash
    # /usr/local/bin/discourse-backup-sync.sh
    set -euo pipefail
    SRC=/var/discourse/shared/standalone/backups/default
    DEST=user@backup-host:/srv/backups/discourse/
    
    rsync -az --delete "$SRC/" "$DEST"
    shell
    chmod +x /usr/local/bin/discourse-backup-sync.sh

    Add to root's crontab, offset from Discourse's own backup window:

    shell
    30 4 * * * /usr/local/bin/discourse-backup-sync.sh >> /var/log/discourse-backup-sync.log 2>&1

    Alternatively, configure S3-compatible backup storage directly in /admin/backups and skip the rsync entirely. That is the cleaner option if you already have object storage.

    Restore drill

    Test a restore at least once before you need one:

    1. Enable allow_restore at /admin/backups.
    2. Upload the backup file to /var/discourse/shared/standalone/backups/default/ on the target box.
    3. It appears in the admin backups list. Click Restore.
    4. Watch the log tab. The instance logs you out when it completes.

    9. Upgrades

    Two paths, and you need to know when each applies.

    Web UI upgrades (routine)

    Visit https://forum.example.com/admin/upgrade and click Upgrade. This handles Discourse core and plugins without a container rebuild. Use this for the majority of updates.

    Command-line rebuild (required for app.yml changes and Docker image updates)

    shell
    cd /var/discourse
    git pull
    ./launcher rebuild app

    Do this when:

    • You edited app.yml (added a plugin, changed SMTP, changed workers)
    • The web upgrade tells you a rebuild is required
    • You are moving between major base image versions

    The rebuild takes the site down for several minutes. On a 2 GB plan it can take considerably longer, and this is where the swap you configured earns its keep.

    Adding plugins

    Plugins go in the after_code hook in app.yml:

    shell
    hooks:
      after_code:
        - exec:
            cd: $home/plugins
            cmd:
              - git clone --depth 1 https://github.com/discourse/docker_manager.git
              - git clone --depth 1 https://github.com/discourse/discourse-solved.git
              - git clone --depth 1 https://github.com/discourse/discourse-calendar.git

    Keep docker_manager in that list. It is what powers the web upgrade UI. Then:

    shell
    ./launcher rebuild app

    Every plugin you add increases bootstrap time and memory footprint. On a 2 GB RamNode plan, be disciplined about which ones actually earn their place.

    10. Operations reference

    shell
    cd /var/discourse
    
    ./launcher start app          # start
    ./launcher stop app           # stop
    ./launcher restart app        # restart without rebuilding
    ./launcher rebuild app        # rebuild after app.yml changes
    ./launcher enter app          # shell inside the container
    ./launcher logs app           # container logs
    ./launcher destroy app        # stop and remove the container (data in shared/ survives)
    ./launcher cleanup            # prune old Docker images, reclaims real disk

    ./launcher cleanup matters on RamNode. Old images accumulate across rebuilds and will quietly consume 10 GB or more. Run it after every few rebuilds.

    Check resource usage:

    shell
    docker stats --no-stream
    free -h
    df -h

    11. Troubleshooting

    502 Bad Gateway that does not clear. Unicorn failed to start. ./launcher logs app and look for the actual exception. Usually OOM during boot on undersized plans.

    Bootstrap killed partway through. Almost always memory. Confirm with dmesg | grep -i oom. Add swap, reduce UNICORN_WORKERS, retry.

    Registration emails never arrive. Check /admin/email/skipped and /sidekiq/retries. Confirm your provider is not blocking the send. Remember that port 25 is not a path available to you on RamNode, so if you configured a plain sendmail somewhere it will never work.

    Let's Encrypt renewal fails. Confirm port 80 is open and not proxied by Cloudflare. ./launcher rebuild app re-triggers issuance.

    Disk full. Run ./launcher cleanup. Then check backup retention and /var/discourse/shared/standalone/uploads/.

    Site is slow under normal load. Look at /sidekiq queue depth first, then docker stats. If Sidekiq is backed up and RAM is fine, you are CPU-bound and want more vCPU. If RAM is pinned, reduce workers or size up.

    12. When Discourse is the wrong choice

    Be honest about the tradeoff. Discourse gives you the best-in-class moderation tooling, trust levels, and community management features of any open-source forum. It also demands 4 GB of RAM to be comfortable, mandates a working transactional email provider, and its rebuild cycle is slow.

    If you want a forum on a 1 GB RamNode plan, or you want something you can host next to five other PHP apps on one box, look at Flarum instead. If you want real-time and a JavaScript stack with a smaller footprint, look at NodeBB. Both have companion guides.


    Quick reference

    shell
    # Install
    wget -qO- https://raw.githubusercontent.com/discourse/discourse_docker/main/install-discourse | sudo bash
    
    # Config
    /var/discourse/containers/app.yml
    
    # Data
    /var/discourse/shared/standalone/
    
    # Rebuild after config change
    cd /var/discourse && ./launcher rebuild app
    
    # Reclaim disk
    cd /var/discourse && ./launcher cleanup