Apps & Collaboration¶
🛠️ The Apps I Actually Use¶
These are the stacks I keep coming back to because they feel like real services, not just containers with a port exposed.
They are where storage, URLs, background jobs, login, and database choices start mattering all at once, which is exactly why I am documenting them.
☁️ Nextcloud¶
Personal Cloud¶
Nextcloud is one of those tools I like because it stops being "just a file app" pretty quickly. The useful part for me is not only sync and sharing, but the way it forces a cleaner setup around storage, database, cache, proxying, and background jobs.
Config¶
This stack splits the web app and background cron work into separate containers. That keeps scheduled maintenance out of the main request path. It also wires in trusted domain settings, reverse-proxy hints, larger PHP limits, and external Postgres and Redis connections.
Compose¶
name: nextcloud
networks:
nextcloud:
services:
nextcloud:
container_name: nextcloud
image: nextcloud:${NEXTCLOUD_VERSION:-stable}
volumes:
- ${NEXTCLOUD_DATA_PATH}:/var/www/html
networks:
- nextcloud
ports:
- ${NEXTCLOUD_PORT:-8080}:80
environment:
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_URL:-localhost}
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
OVERWRITEPROTOCOL: ${PROTOCOL:-https}
OVERWRITECLIURL: ${PROTOCOL:-https}://${NEXTCLOUD_URL:-localhost}
PHP_VALUE: "session.save_handler=redis\nsession.save_path='tcp://${REDIS_HOST}:${REDIS_PORT:-6379}?auth=${REDIS_PASS}&database=0'"
PHP_UPLOAD_LIMIT: 5G
PHP_MEMORY_LIMIT: 2G
NC_allow_local_remote_servers: 'true'
POSTGRES_HOST: ${POSTGRESQL_HOST:?POSTGRESQL_HOST; no database host}
POSTGRES_HOST_PORT: ${POSTGRESQL_PORT:-5432}
POSTGRES_DB: ${NEXTCLOUD_PG_DB:?NEXTCLOUD_PG_DB; no database name}
POSTGRES_USER: ${NEXTCLOUD_PG_USER:?NEXTCLOUD_PG_USER; no database user}
POSTGRES_PASSWORD: ${NEXTCLOUD_PG_PASS:?NEXTCLOUD_PG_PASS; no database password}
REDIS_HOST: ${REDIS_HOST:?REDIS_HOST; no redis host}
REDIS_HOST_PORT: ${REDIS_PORT:-6379}
REDIS_HOST_PASSWORD: ${REDIS_PASS}
restart: unless-stopped
nextcloud-cron:
container_name: nextcloud-cron
image: nextcloud:${NEXTCLOUD_VERSION:-stable}
volumes:
- ${NEXTCLOUD_DATA_PATH}:/var/www/html
networks:
- nextcloud
entrypoint: /cron.sh
environment:
NEXTCLOUD_CRON_PERIOD: 10m
POSTGRES_HOST: ${POSTGRESQL_HOST:?POSTGRESQL_HOST; no database host}
POSTGRES_HOST_PORT: ${POSTGRESQL_PORT:-5432}
POSTGRES_DB: ${NEXTCLOUD_PG_DB:?NEXTCLOUD_PG_DB; no database name}
POSTGRES_USER: ${NEXTCLOUD_PG_USER:?NEXTCLOUD_PG_USER; no database user}
POSTGRES_PASSWORD: ${NEXTCLOUD_PG_PASS:?NEXTCLOUD_PG_PASS; no database password}
REDIS_HOST: ${REDIS_HOST:?REDIS_HOST; no redis host}
REDIS_HOST_PORT: ${REDIS_PORT:-6379}
REDIS_HOST_PASSWORD: ${REDIS_PASS}
restart: unless-stopped
Example env¶
NEXTCLOUD_VERSION=stable
NEXTCLOUD_PORT=8080
NEXTCLOUD_URL=nextcloud.example.com
PROTOCOL=https
TRUSTED_PROXIES="192.168.0.0/16"
NEXTCLOUD_DATA_PATH="/srv/nextcloud"
POSTGRESQL_HOST=postgresql
POSTGRESQL_PORT=5432
NEXTCLOUD_PG_DB=nextcloud
NEXTCLOUD_PG_USER=nextcloud
NEXTCLOUD_PG_PASS=change-me
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASS=change-me
Vars¶
| Variable | Purpose | Why it matters |
|---|---|---|
NEXTCLOUD_VERSION |
Image tag | Pin when you want predictable upgrades |
NEXTCLOUD_PORT |
Host port | Used when not behind a proxy |
NEXTCLOUD_URL |
Public hostname | Needed for trusted domains and generated links |
PROTOCOL |
URL scheme | Important behind HTTPS reverse proxies |
TRUSTED_PROXIES |
Proxy IP ranges | Helps Nextcloud trust the real client path |
NEXTCLOUD_DATA_PATH |
App data path | Main persistent app mount |
POSTGRESQL_HOST / POSTGRESQL_PORT |
Database endpoint | Nextcloud stores metadata there |
NEXTCLOUD_PG_DB / NEXTCLOUD_PG_USER / NEXTCLOUD_PG_PASS |
Database credentials | Required for startup |
REDIS_HOST / REDIS_PORT / REDIS_PASS |
Redis endpoint | Used for locks, sessions, and cache |
🐙 Gitea¶
Self-Hosted Git¶
Gitea is the kind of service I like showing because it looks simple on the surface, but the good version of it has proper database, cache, mail, and URL settings underneath. That makes it a nice "small app, real platform thinking" example.
Config¶
The container stores everything under /data, exposes both HTTP and SSH, and configures database, cache, sessions, queues, and mail entirely through environment variables. Redis is used three times here, which is exactly the sort of detail that makes the stack feel properly wired instead of half-finished.
Compose¶
name: gitea
services:
server:
image: gitea/gitea:${GITEA_VERSION:-latest}
container_name: gitea
ports:
- ${GITEA_PORT:-3000}:3000
- ${GITEA_PORT_SSH:-2221}:${GITEA_PORT_SSH:-2221}
volumes:
- ${GITEA_DATA_PATH:-gitea}:/data
environment:
TZ: ${TZ:-Etc/UTC}
USER_UID: ${UID:-1000}
USER_GID: ${GID:-1000}
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: ${POSTGRESQL_HOST:?POSTGRESQL_HOST; no database host}:${POSTGRESQL_PORT:-5432}
GITEA__database__NAME: ${GITEA_PG_DB:?GITEA_PG_DB; no database name}
GITEA__database__USER: ${GITEA_PG_USER:?GITEA_PG_USER; no database user}
GITEA__database__PASSWD: ${GITEA_PG_PASS:?GITEA_PG_PASS; no database password}
GITEA__cache__ADAPTER: redis
GITEA__cache__HOST: redis://${REDIS_PASS}@${REDIS_HOST}:${REDIS_PORT:-6379}/${REDIS_DBID}
GITEA__session__PROVIDER: redis
GITEA__session__PROVIDER_CONFIG: redis://${REDIS_PASS}@${REDIS_HOST}:${REDIS_PORT:-6379}/${REDIS_DBID}
GITEA__queue__TYPE: redis
GITEA__queue__CONN_STR: redis://${REDIS_PASS}@${REDIS_HOST}:${REDIS_PORT:-6379}/${REDIS_DBID}
GITEA__mailer__ENABLED: "true"
GITEA__mailer__PROTOCOL: smtp+starttls
GITEA__mailer__FROM: ${EMAIL_FROM}
GITEA__mailer__SMTP_ADDR: ${EMAIL_HOST}
GITEA__mailer__SMTP_PORT: ${EMAIL_PORT}
GITEA__mailer__USER: ${EMAIL_USER}
GITEA__mailer__PASSWD: ${EMAIL_PASS}
DOMAIN: ${GITEA_DOMAIN:-localhost}
ROOT_URL: ${GITEA_ROOT_URL:-https://${GITEA_DOMAIN:-localhost}:${GITEA_PORT:-3000}/}
SSH_DOMAIN: ${GITEA_DOMAIN:-localhost}
SSH_PORT: ${GITEA_PORT_SSH:-2221}
USE_COMPAT_SSH_URI: ${GITEA_COMPAT_SSH_URI:-false}
restart: unless-stopped
Example env¶
UID=1000
GID=1000
TZ="Asia/Tokyo"
GITEA_VERSION=latest
GITEA_PORT=3000
GITEA_PORT_SSH=2221
GITEA_DOMAIN=git.example.com
GITEA_ROOT_URL="https://git.example.com/"
GITEA_DATA_PATH="/srv/gitea"
POSTGRESQL_HOST=postgresql
POSTGRESQL_PORT=5432
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DBID=0
REDIS_PASS=""
GITEA_PG_DB=gitea
GITEA_PG_USER=gitea
GITEA_PG_PASS=change-me
EMAIL_FROM="Gitea <noreply@example.com>"
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=user@example.com
EMAIL_PASS=app-password
Vars¶
| Variable | Purpose | Why it matters |
|---|---|---|
GITEA_VERSION |
Image tag | Pinning avoids surprise changes |
GITEA_PORT / GITEA_PORT_SSH |
HTTP and SSH ports | Needed for web access and clone URLs |
GITEA_DOMAIN / GITEA_ROOT_URL |
Public URL settings | Keeps links and callbacks correct |
GITEA_DATA_PATH |
Persistent data path | Repositories and app state live here |
POSTGRESQL_HOST / POSTGRESQL_PORT |
Database endpoint | Main app database |
GITEA_PG_DB / GITEA_PG_USER / GITEA_PG_PASS |
Database credentials | Required for startup |
REDIS_HOST / REDIS_PORT / REDIS_DBID / REDIS_PASS |
Redis connection | Powers cache, sessions, and queues |
EMAIL_FROM / EMAIL_HOST / EMAIL_PORT / EMAIL_USER / EMAIL_PASS |
SMTP settings | Needed for notifications and resets |
UID / GID / TZ |
Host compatibility | Helps with file ownership and timestamps |
📘 Wiki.js¶
Docs Home¶
Wiki.js is one I like for the opposite reason from some of the bigger stacks: it stays pretty lean, but still benefits a lot from being wired to a real Postgres backend instead of pretending persistence does not matter.
Config¶
This recipe is intentionally lean: one app container, one exposed port, and a PostgreSQL connection defined through environment variables. It is easy to drop into an existing shared-database setup.
Compose¶
name: wikijs
services:
wikijs:
image: requarks/wiki:${WIKIJS_VERSION:-latest}
container_name: wikijs
ports:
- ${WIKIJS_PORT:-3000}:3000
environment:
DB_TYPE: postgres
DB_HOST: ${POSTGRESQL_HOST:?POSTGRESQL_HOST; no database host}
DB_PORT: ${POSTGRESQL_PORT:-5432}
DB_NAME: ${WIKIJS_PG_DB:?WIKIJS_PG_DB; no database name}
DB_USER: ${WIKIJS_PG_USER:?WIKIJS_PG_USER; no database user}
DB_PASS: ${WIKIJS_PG_PASS:?WIKIJS_PG_PASS; no database password}
restart: unless-stopped
Example env¶
WIKIJS_VERSION=latest
WIKIJS_PORT=3000
POSTGRESQL_HOST=postgresql
POSTGRESQL_PORT=5432
WIKIJS_PG_DB=wikijs
WIKIJS_PG_USER=wikijs
WIKIJS_PG_PASS=change-me
Vars¶
| Variable | Purpose | Why it matters |
|---|---|---|
WIKIJS_VERSION |
Image tag | Pinning is still a good habit |
WIKIJS_PORT |
Host port | Default app access port |
POSTGRESQL_HOST / POSTGRESQL_PORT |
Database endpoint | Wiki.js needs Postgres to boot |
WIKIJS_PG_DB / WIKIJS_PG_USER / WIKIJS_PG_PASS |
Database credentials | Core connection settings |
🔐 Authentik¶
Single Sign-On¶
Authentik is the service that makes the rest of the stack feel like one environment instead of a pile of unrelated logins. I like keeping it here because it shows the jump from "app container" to "shared identity layer."
Config¶
The recipe includes Postgres, Redis, a dedicated server container, and a separate worker container. That server/worker split is the part I like most here. It shows the service is being run the way it expects to be run, not squeezed into a single process just because it is possible.
Compose¶
name: authentik
volumes:
postgresql:
name: ${POSTGRES_VOLUME:-postgresql}
redis:
name: ${REDIS_VOLUME:-redis}
authentik-templates:
name: ${AUTHENTIK_VOLUME_TEMPLATES:-authentik_templates}
authentik-certs:
name: ${AUTHENTIK_VOLUME_CERTS:-authentik_certs}
networks:
authentik:
services:
postgresql:
image: postgres:${POSTGRES_VERSION:-17-alpine}
container_name: postgresql
networks:
- authentik
volumes:
- postgresql:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${AUTHENTIK_PG_DB:-authentik}
POSTGRES_USER: ${AUTHENTIK_PG_USER:-authentik}
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
healthcheck:
interval: 30s
start_period: 20s
timeout: 10s
retries: 5
test:
- CMD-SHELL
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
mem_reservation: 256mb
restart: always
redis:
image: redis:${REDIS_VERSION:-alpine}
container_name: redis
networks:
- authentik
volumes:
- redis:/data
command: --save 60 1 --loglevel warning
healthcheck:
interval: 30s
start_period: 20s
timeout: 10s
retries: 5
test:
- CMD-SHELL
- redis-cli ping | grep PONG
mem_reservation: 32mb
restart: always
authentik-server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:-2025.10}
container_name: authentik-server
command: server
networks:
- authentik
ports:
- ${AUTHENTIK_PORT:-9443}:9443
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__PORT: 5432
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PORT: 6379
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_EMAIL__FROM: ${EMAIL_FROM}
AUTHENTIK_EMAIL__HOST: ${EMAIL_HOST}
AUTHENTIK_EMAIL__PORT: ${EMAIL_PORT}
AUTHENTIK_EMAIL__USERNAME: ${EMAIL_USER}
AUTHENTIK_EMAIL__PASSWORD: ${EMAIL_PASS}
AUTHENTIK_EMAIL__TIMEOUT: 30
AUTHENTIK_EMAIL__USE_TLS: true
AUTHENTIK_EMAIL__USE_SSL: false
volumes:
- authentik-templates:/templates
mem_reservation: 384mb
restart: always
authentik-worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:-2025.10}
container_name: authentik-worker
command: worker
networks:
- authentik
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__PORT: 5432
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PORT: 6379
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_EMAIL__FROM: ${EMAIL_FROM}
AUTHENTIK_EMAIL__HOST: ${EMAIL_HOST}
AUTHENTIK_EMAIL__PORT: ${EMAIL_PORT}
AUTHENTIK_EMAIL__USERNAME: ${EMAIL_USER}
AUTHENTIK_EMAIL__PASSWORD: ${EMAIL_PASS}
AUTHENTIK_EMAIL__TIMEOUT: 30
AUTHENTIK_EMAIL__USE_TLS: true
AUTHENTIK_EMAIL__USE_SSL: false
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- authentik-certs:/certs:z
- authentik-templates:/templates
mem_reservation: 192mb
restart: always
Example env¶
AUTHENTIK_VERSION=2025.10
AUTHENTIK_PORT=9443
POSTGRES_VERSION=17-alpine
REDIS_VERSION=alpine
AUTHENTIK_PG_DB=authentik
AUTHENTIK_PG_USER=authentik
AUTHENTIK_PG_PASS=change-me
AUTHENTIK_SECRET_KEY=change-me
EMAIL_FROM="Authentik <noreply@example.com>"
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=user@example.com
EMAIL_PASS=app-password
POSTGRES_VOLUME=postgresql
REDIS_VOLUME=redis
AUTHENTIK_VOLUME_TEMPLATES=authentik_templates
AUTHENTIK_VOLUME_CERTS=authentik_certs
Vars¶
| Variable | Purpose | Why it matters |
|---|---|---|
AUTHENTIK_VERSION |
Image tag | Worth pinning because auth stacks are sensitive to upgrades |
AUTHENTIK_PORT |
Public port | Main user-facing access point |
AUTHENTIK_PG_DB / AUTHENTIK_PG_USER / AUTHENTIK_PG_PASS |
Postgres settings | Core database connection |
AUTHENTIK_SECRET_KEY |
App secret | Critical security setting |
POSTGRES_VERSION / REDIS_VERSION |
Dependency versions | Useful when standardizing the stack |
EMAIL_FROM / EMAIL_HOST / EMAIL_PORT / EMAIL_USER / EMAIL_PASS |
SMTP settings | Needed for account-related mail |
POSTGRES_VOLUME / REDIS_VOLUME |
Data volumes | Keep backing services persistent |
AUTHENTIK_VOLUME_TEMPLATES / AUTHENTIK_VOLUME_CERTS |
App volumes | Keep templates and certificates |