Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The MVP is complete. The current repository baseline is the finished admin-opera
- [Architecture](docs/architecture.md)
- [MVP scope](docs/mvp.md)
- [Feature implementation plans](docs/features/README.md)
- [Production deployment (AWS EC2 via SSM)](deploy/README.md)

## Quick start

Expand Down Expand Up @@ -95,6 +96,8 @@ Optional repository variable:

- `DOCKERHUB_IMAGE` (defaults to `<DOCKERHUB_USERNAME>/song-vault`)

To roll a published tag out to production, see [deploy/README.md](deploy/README.md).

## Environment variables

- `TELEGRAM_BOT_TOKEN`: bot token from BotFather
Expand Down
29 changes: 29 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Production environment for deploy/compose.yaml
# Copy to /opt/song-vault/.env on the EC2 host and fill in real values.
# Never commit the filled-in file — it contains live secrets.

# Image tag to deploy (the Git tag published to Docker Hub, e.g. v1.1.0)
SONG_VAULT_IMAGE=scottishwidow/song-vault:v1.1.0

# Pin the object-storage images (optional; defaults to :latest)
MINIO_IMAGE=minio/minio:RELEASE.2025-09-07T16-13-09Z
MINIO_MC_IMAGE=minio/mc:RELEASE.2025-08-13T08-35-41Z

# Telegram
TELEGRAM_BOT_TOKEN=replace-me
ADMIN_TELEGRAM_USER_IDS=123456789

# Postgres
POSTGRES_DB=song_vault
POSTGRES_USER=song_vault
POSTGRES_PASSWORD=replace-me

# MinIO / chart storage (bot credentials are derived from the MinIO root user)
MINIO_ROOT_USER=replace-me
MINIO_ROOT_PASSWORD=replace-me
CHART_STORAGE_BUCKET=song-vault-charts
CHART_STORAGE_REGION=us-east-1

# App
LOG_LEVEL=INFO
BOT_POLL_INTERVAL=1.0
67 changes: 67 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Production deployment (AWS EC2 via SSM)

Song Vault runs in production as a Docker Compose stack on an **amd64 AWS EC2 instance**,
managed through **AWS Systems Manager Session Manager** (no inbound SSH). The host holds:

```
/opt/song-vault/
├── compose.yaml # this directory's compose.yaml
└── .env # from .env.example, filled with real secrets (chmod 600, never committed)
```

The stack mirrors local development: `db` (Postgres 17), `minio` + `minio-init`
(S3-compatible chart storage), and `bot`. State lives in two named Docker volumes,
`song-vault_postgres-data` and `song-vault_minio-data`.

## Prerequisites

- An EC2 instance (x86_64) with Docker + the Compose plugin installed.
- The instance registered as an SSM managed instance (role with `AmazonSSMManagedInstanceCore`).
- `AWS_PROFILE` configured locally with SSM access; outbound internet on the instance for image pulls.

## Connect

```bash
aws ssm start-session --target <INSTANCE_ID>
# then: sudo -i; cd /opt/song-vault
```

Or run one-off commands without an interactive shell via `aws ssm send-command`
with the `AWS-RunShellScript` document.

## Deploy / upgrade

Images are published to Docker Hub by `.github/workflows/docker-publish.yml` on Git tags
matching `v*.*.*` (see the repo README). To roll out a new version:

1. Push a release tag (e.g. `git tag v1.2.0 && git push origin v1.2.0`) and let CI publish it.
2. On the EC2 host, bump `SONG_VAULT_IMAGE` in `/opt/song-vault/.env` to the new tag.
3. Pull and restart:

```bash
cd /opt/song-vault
docker compose pull
docker compose up -d
```

The `bot` container runs `alembic upgrade head` on start, so migrations apply automatically.

## Backups

Data is small. To snapshot:

```bash
# Postgres (logical, portable)
docker exec song-vault-db-1 pg_dump -U song_vault -d song_vault -Fc > song_vault.dump

# MinIO (exact volume copy)
docker run --rm -v song-vault_minio-data:/data -v "$PWD":/b alpine \
tar czf /b/minio-data.tgz -C /data .
```

Restore on a fresh host: load the MinIO volume from the tarball before first start, bring up
`db`, then `cat song_vault.dump | docker exec -i song-vault-db-1 pg_restore --clean --if-exists
--no-owner -U song_vault -d song_vault`.

> Note: there is only one Telegram long-poller per bot token. When migrating between hosts,
> stop the old `bot` container before starting the new one to avoid a `getUpdates` conflict.
109 changes: 109 additions & 0 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: song-vault

services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-song_vault}
POSTGRES_USER: ${POSTGRES_USER:-song_vault}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-song_vault} -d ${POSTGRES_DB:-song_vault}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

minio:
image: ${MINIO_IMAGE:-minio/minio:latest}
command: server /data
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}
volumes:
- minio-data:/data
restart: unless-stopped
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

minio-init:
image: ${MINIO_MC_IMAGE:-minio/mc:latest}
depends_on:
minio:
condition: service_started
entrypoint:
- /bin/sh
- -c
- |
until mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"; do sleep 2; done
mc mb --ignore-existing "local/$$CHART_STORAGE_BUCKET"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}
CHART_STORAGE_BUCKET: ${CHART_STORAGE_BUCKET:?set CHART_STORAGE_BUCKET}
restart: "no"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

bot:
image: ${SONG_VAULT_IMAGE:?set SONG_VAULT_IMAGE, e.g. yourname/song-vault:v1.0.0}
pull_policy: always
depends_on:
db:
condition: service_healthy
minio-init:
condition: service_completed_successfully
environment:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
ADMIN_TELEGRAM_USER_IDS: ${ADMIN_TELEGRAM_USER_IDS:?set ADMIN_TELEGRAM_USER_IDS}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-song_vault}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-song_vault}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
BOT_POLL_INTERVAL: ${BOT_POLL_INTERVAL:-1.0}
CHART_STORAGE_ENDPOINT_URL: http://minio:9000
CHART_STORAGE_REGION: ${CHART_STORAGE_REGION:-us-east-1}
CHART_STORAGE_BUCKET: ${CHART_STORAGE_BUCKET:?set CHART_STORAGE_BUCKET}
CHART_STORAGE_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
CHART_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
CHART_STORAGE_USE_SSL: "false"
CHART_STORAGE_FORCE_PATH_STYLE: "true"
PYTHONDONTWRITEBYTECODE: "1"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 256
mem_limit: 512m
restart: unless-stopped
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

volumes:
postgres-data:
minio-data:
Loading