You can host multiple standalone Discourse installs on a single server (separate containers / separate ports / separate app.yml), without using Discourse “multisite”.
It’s more manual than multisite, but it keeps instances isolated and makes it easier to migrate an individual site to its own server later.
One practical pattern is:
• external Postgres (single instance)
• external Redis (single instance)
• multiple Discourse web containers
• one Sidekiq node
• reverse proxy with health checks
This avoids multisite entirely while still allowing cost savings on low-traffic setups.
DISCOURSE MULTI-CONTAINER RUNBOOK
External Postgres + Redis + HAProxy + app1 / app2
- HOST PACKAGES
| Step | Command |
|---|---|
| Update system | apt-get update |
| Install base tools | apt-get install -y ca-certificates curl gnupg lsb-release |
| Install HAProxy + certbot + socat | apt-get install -y haproxy certbot socat |
- DOCKER NETWORK (REQUIRED)
A user-defined Docker network is required so containers can resolve each other by name.
| Step | Command |
|---|---|
| Create network | docker network create discourse-net |
| Verify | docker network ls | grep discourse-net |
This allows:
• DISCOURSE_DB_HOST=pg
• DISCOURSE_REDIS_HOST=redis
to work correctly.
- SECRETS
| Purpose | Command |
|---|---|
| Postgres superuser | export PG_SUPERPASS='REPLACE_ME_super_strong' |
| Discourse DB password | export DISCOURSE_DBPASS='REPLACE_ME_discordb_strong' |
| Redis password | export REDIS_PASS='REPLACE_ME_redis_strong' |
| Secret key base | export SECRET_KEY_BASE="$(openssl rand -hex 64)" |
- POSTGRES CONTAINER
| Step | Command |
|---|---|
| Create directory | mkdir -p /var/discourse/external/postgres |
| Run container | docker run -d --name pg --restart=always --network=discourse-net -e POSTGRES_PASSWORD="$PG_SUPERPASS" -v /var/discourse/external/postgres:/var/lib/postgresql/data postgres:15 |
| Verify | docker ps | grep pg |
- CREATE DATABASE
| Step | Command |
|---|---|
| Create role | docker exec -it pg psql -U postgres -c "CREATE ROLE discourse LOGIN PASSWORD '$DISCOURSE_DBPASS';" |
| Create DB | docker exec -it pg psql -U postgres -c "CREATE DATABASE discourse OWNER discourse ENCODING 'UTF8' TEMPLATE template0;" |
| Text search | docker exec -it pg psql -U postgres -d discourse -c "ALTER DATABASE discourse SET default_text_search_config = 'pg_catalog.english';" |
| Test login | docker exec -it pg psql -U discourse -d discourse -c "select 1;" |
- PGVECTOR EXTENSION
Required for modern Discourse versions.
| Step | Command |
|---|---|
| Install | docker exec -it pg bash -lc 'apt-get update && apt-get install -y postgresql-15-pgvector && rm -rf /var/lib/apt/lists/*' |
| Create extension | docker exec -it pg psql -U postgres -d discourse -c "CREATE EXTENSION IF NOT EXISTS vector;" |
| Verify | docker exec -it pg psql -U postgres -d discourse -c "SELECT extname FROM pg_extension WHERE extname='vector';" |
- REDIS CONTAINER
| Step | Command |
|---|---|
| Create directory | mkdir -p /var/discourse/external/redis |
Redis config template:
requirepass REPLACE_ME_REDIS
appendonly yes
save 900 1
save 300 10
save 60 10000
| Step | Command |
|---|---|
| Write config | tee /var/discourse/external/redis/redis.conf >/dev/null <<EOF |
| Insert password | sed -i "s/REPLACE_ME_REDIS/$REDIS_PASS/" /var/discourse/external/redis/redis.conf |
| Run redis | docker run -d --name redis --restart=always --network=discourse-net -v /var/discourse/external/redis:/data -v /var/discourse/external/redis/redis.conf:/usr/local/etc/redis/redis.conf redis:7-alpine redis-server /usr/local/etc/redis/redis.conf |
| Test auth | docker exec -it redis redis-cli -a "$REDIS_PASS" ping |
- DISCOURSE DIRECTORY LAYOUT
| Step | Command |
|---|---|
| Create base dir | mkdir -p /var/discourse |
| Enter | cd /var/discourse |
| Clone repo | git clone https://github.com/discourse/discourse_docker.git |
| Containers dir | mkdir -p /var/discourse/containers |
| Shared logs | mkdir -p /var/discourse/shared/web-only/log/var-log |
| Link containers | ln -sfn /var/discourse/containers /var/discourse/discourse_docker/containers |
| Link launcher | ln -sfn /var/discourse/discourse_docker/launcher /var/discourse/launcher |
- APPLICATION CONTAINERS
app1.yml
• web + sidekiq
• port 8001
docker_args: "--network=discourse-net"
expose:
- "8001:80"
app2.yml
• web only
• port 8002
• sidekiq disabled
docker_args: "--network=discourse-net"
expose:
- "8002:80"
run:
- exec: bash -lc 'mkdir -p /etc/service/sidekiq && touch /etc/service/sidekiq/down'
- BOOTSTRAP
| Step | Command |
|---|---|
| Enter | cd /var/discourse/discourse_docker |
| Bootstrap app1 | ./launcher bootstrap app1 |
| Start app1 | ./launcher start app1 |
| Bootstrap app2 | ./launcher bootstrap app2 |
| Start app2 | ./launcher start app2 |
- HEALTH CHECKS
| Step | Command |
|---|---|
| app1 | curl -sSf http://127.0.0.1:8001/srv/status |
| app2 | curl -sSf http://127.0.0.1:8002/srv/status |
| sidekiq app1 | docker exec -it app1 pgrep -fa sidekiq |
| sidekiq app2 | `docker exec -it app2 pgrep -fa sidekiq |
- TLS CERTIFICATE
| Step | Command |
|---|---|
| Stop proxy | systemctl stop haproxy |
| Issue cert | certbot certonly --standalone -d example.com --agree-tos -m you@example.com --non-interactive |
| Start proxy | systemctl start haproxy |
- HAPROXY LOGIC
frontend fe_discourse
bind :80
bind :443 ssl crt /etc/letsencrypt/live/example.com/haproxy.pem
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
redirect scheme https code 301 if !{ ssl_fc }
use_backend be_discourse if { nbsrv(be_discourse) gt 0 }
default_backend be_maint
backend be_discourse
balance roundrobin
option httpchk GET /srv/status
server app1 127.0.0.1:8001 check
server app2 127.0.0.1:8002 check
backend be_maint
http-request return status 503 content-type text/html string "<h1>Maintenance</h1>"
- ZERO-DOWNTIME REBUILDS
| Step | Command |
|---|---|
| Disable app1 | echo "disable server be_discourse/app1" | socat stdio /run/haproxy/admin.sock |
| Rebuild app1 | ./launcher rebuild app1 |
| Enable app1 | echo "enable server be_discourse/app1" | socat stdio /run/haproxy/admin.sock |
| Step | Command |
|---|---|
| Disable app2 | echo "disable server be_discourse/app2" | socat stdio /run/haproxy/admin.sock |
| Rebuild app2 | ./launcher rebuild app2 |
| Enable app2 | echo "enable server be_discourse/app2" | socat stdio /run/haproxy/admin.sock |
END
Docker networking required
External Postgres and Redis
pgvector installed
Sidekiq isolated to app1
HAProxy health checks enabled
Maintenance fallback active
Rolling rebuilds supported
Migrating one site to its own server later
One advantage of running fully standalone Discourse installs (instead of multisite) is that migration is straightforward and low-risk.
Each Discourse instance already has:
• its own container
• its own uploads
• its own database
• its own Redis usage
• its own app.yml
No multisite disentangling is required.
High-level migration steps
- Provision a new VPS
Install Docker and Discourse normally on the new server.
Do not configure multisite.
- Create a full backup
From the source site:
Admin → Backups → Create Backup
Download the backup file.
This includes:
• database
• uploads
• users
• settings
• themes
- Restore on the new server
On the new server:
• complete initial setup
• log in as admin
• upload the backup
• restore
Discourse handles schema compatibility automatically.
- DNS cutover
Update the domain’s A record to point to the new server IP.
Once DNS propagates, users are transparently moved.
- Decommission old container
On the original server:
• stop the old container
• remove it when confident
Other Discourse installs on the same host are unaffected.
Why this is simpler than multisite
In multisite setups, migration often requires:
• separating databases
• extracting site-specific data
• adjusting multisite.yml
• reworking Sidekiq
• reconfiguring uploads and email
With standalone installs, none of that is necessary.
Each site is already independent.
Summary
This approach trades a little operational complexity early on
for very simple separation later.
It works particularly well during experimentation
or early-stage community building.
When this approach is probably not a good fit
This setup is usually not a good idea if:
• the sites expect moderate or high traffic early on
• you rely heavily on official Discourse support
• you are uncomfortable debugging Docker, networking, or reverse proxies
• uptime requirements are strict or business-critical
• multiple sites are tightly coupled operationally
• you expect frequent plugin experimentation across all instances
In these cases, either:
• a supported multisite setup
or
• one Discourse install per server
will usually result in fewer operational surprises.
Important note
This approach increases infrastructure flexibility,
but also increases responsibility on the administrator.
It works best when the person running it is comfortable owning the full stack
and treating occasional breakage as part of the learning process.
If stability and supportability are the primary goals,
a supported configuration is almost always the better choice.