Initial commit
All checks were successful
Deploy storage stack / deploy (push) Successful in 24s

This commit is contained in:
Sergei Poljanski 2026-05-26 17:13:14 +03:00
commit 479e3d5cf9
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
10 changed files with 466 additions and 0 deletions

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Storage stack — Postgres + MinIO
# Copy to .env for local use. Production .env is regenerated by the deploy
# workflow from Forgejo secrets.
# Postgres superuser (used only by init + admin tasks)
POSTGRES_PASSWORD=change-me
# MinIO root account (used only by init + admin tasks)
MINIO_ROOT_USER=storage-admin
MINIO_ROOT_PASSWORD=change-me-32-chars-min
# Per-app credentials provisioned by the init containers.
# These are also added as secrets on every consuming app's repo.
ASXPIO_DB_PASSWORD=change-me
ASXPIO_S3_ACCESS_KEY=change-me
ASXPIO_S3_SECRET_KEY=change-me

View file

@ -0,0 +1,64 @@
name: Deploy storage stack
on:
push:
branches:
- main
jobs:
deploy:
runs-on: arch-latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm nodejs openssh rsync
- name: Checkout code
uses: actions/checkout@v4
- name: Copy compose + init scripts
uses: easingthemes/ssh-deploy@v5.1.0
with:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REMOTE_HOST: ${{ secrets.DEPLOY_IP }}
REMOTE_USER: ${{ secrets.DEPLOY_USER }}
SOURCE: "docker-compose.yml init/"
TARGET: "/opt/storage/"
- name: Write .env from secrets and bring stack up
uses: appleboy/ssh-action@v1.2.3
env:
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
ASXPIO_DB_PASSWORD: ${{ secrets.ASXPIO_DB_PASSWORD }}
ASXPIO_S3_ACCESS_KEY: ${{ secrets.ASXPIO_S3_ACCESS_KEY }}
ASXPIO_S3_SECRET_KEY: ${{ secrets.ASXPIO_S3_SECRET_KEY }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: POSTGRES_PASSWORD,MINIO_ROOT_USER,MINIO_ROOT_PASSWORD,ASXPIO_DB_PASSWORD,ASXPIO_S3_ACCESS_KEY,ASXPIO_S3_SECRET_KEY
script_stop: true
script: |
umask 077
mkdir -p /opt/storage
cat > /opt/storage/.env <<EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ROOT_USER=${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
ASXPIO_DB_PASSWORD=${ASXPIO_DB_PASSWORD}
ASXPIO_S3_ACCESS_KEY=${ASXPIO_S3_ACCESS_KEY}
ASXPIO_S3_SECRET_KEY=${ASXPIO_S3_SECRET_KEY}
EOF
chmod 600 /opt/storage/.env
chmod +x /opt/storage/init/postgres-init.sh /opt/storage/init/minio-bootstrap.sh
cd /opt/storage
docker compose pull
docker compose up -d postgres minio
# Run init containers (idempotent) and wait for them to exit.
docker compose up --exit-code-from postgres-init postgres-init
docker compose up --exit-code-from minio-init minio-init
echo "Storage stack deployed."

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.env
.direnv/
.envrc.local
*.log

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# storage
Postgres + MinIO for asxp.io and any future small services that need a database
or object storage. Single-host, no clustering. Deploys via Forgejo Actions to
the same box that runs the apps.
## What's here
- `docker-compose.yml``postgres`, `minio`, and one-shot init containers.
- `init/postgres-init.sh` — idempotent role/DB bootstrap.
- `init/minio-bootstrap.sh` — idempotent bucket + per-app service account.
- `.forgejo/workflows/deploy.yaml` — pushes compose + `.env` to the prod host
and runs `docker compose up -d`.
## Networking
Two Docker networks:
- `storage` — internal-only; apps join this as an external network to reach
`postgres:5432` and `minio:9000`.
- `traefik` — existing reverse-proxy network. Only MinIO joins it, to expose
the S3 API at `https://s3.asxp.io`.
The MinIO console (`:9001`) is **not** exposed externally. To use it, SSH-port-forward:
```sh
ssh -L 9001:storage-minio:9001 deploy@prod
# then open http://localhost:9001
```
## Adding a new app
1. Pick an app name, e.g. `foo`.
2. Pick a DB password, S3 access key, S3 secret key for it (any random hex).
3. Add Forgejo secrets `FOO_DB_PASSWORD`, `FOO_S3_ACCESS_KEY`, `FOO_S3_SECRET_KEY`
to this repo *and* to the foo repo.
4. Extend `init/postgres-init.sh` with `ensure_role "foo" ...` and `ensure_db "foo" "foo"`.
5. Extend `init/minio-bootstrap.sh` with `ensure_bucket "foo-..."`,
`write_policy "foo-...-rw" "foo-..."`, `ensure_user ... "foo-...-rw"`.
6. Pass the new envs through in `docker-compose.yml` and the deploy workflow.
7. Push.
## State and backups
Two named Docker volumes are stateful:
- `pg_data` (Postgres data dir) — `/var/lib/docker/volumes/storage_pg_data/_data`
- `minio_data` (S3 object bytes) — `/var/lib/docker/volumes/storage_minio_data/_data`
Server-level backup covers both. If you ever move off whole-server backup,
the minimum is `pg_dumpall` for Postgres and `mc mirror` for MinIO.
## Secrets
See [`SECRETS.md`](SECRETS.md) for the full list of Forgejo secrets this repo
needs. Apps that consume storage (asxpio, future) also need the per-app
credentials added on *their* repo so they can connect.

84
SECRETS.md Normal file
View file

@ -0,0 +1,84 @@
# Forgejo secrets — storage repo
Add these to `git.czsk.it/asxpi/storage` → Settings → Actions → Secrets before
the first deploy. All values are opaque to the storage stack; nothing is
domain-specific.
## How to generate a value
```sh
openssl rand -hex 32 # 64-char hex, good for any password/key
openssl rand -base64 24 # 32-char base64, fine for MinIO root password
```
For MinIO **access keys** prefer alphanumeric, 20 chars:
```sh
LC_ALL=C tr -dc 'A-Z0-9' </dev/urandom | head -c 20; echo
```
For MinIO **secret keys**, 40+ chars:
```sh
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 40; echo
```
## Required secrets
### Deploy access (same pattern as the asxpio repo)
| Secret | Value |
|------------------|------------------------------------------------------------|
| `DEPLOY_IP` | Production host IP/hostname. |
| `DEPLOY_USER` | SSH user on the prod host. |
| `DEPLOY_SSH_KEY` | Private SSH key authorised on the prod host. |
Reuse the same key the asxpio repo uses.
### Postgres
| Secret | Value |
|---------------------|--------------------------------------------------------------------------------|
| `POSTGRES_PASSWORD` | Postgres superuser password. `openssl rand -hex 32`. Used only by init/admin. |
### MinIO
| Secret | Value |
|-------------------------|----------------------------------------------------------------------|
| `MINIO_ROOT_USER` | Root username, e.g. `storage-admin`. Any string ≥ 3 chars. |
| `MINIO_ROOT_PASSWORD` | Root password. ≥ 8 chars; use `openssl rand -base64 24`. |
### Per-app credentials provisioned for asxpio
These are also added (with the same values) on the **asxpio repo** so the app
can connect.
| Secret | Value |
|-------------------------|----------------------------------------------------------------|
| `ASXPIO_DB_PASSWORD` | Postgres password for the `asxpio` role. `openssl rand -hex 32`.|
| `ASXPIO_S3_ACCESS_KEY` | 20-char alphanumeric, see snippet above. |
| `ASXPIO_S3_SECRET_KEY` | 40-char alphanumeric, see snippet above. |
## After adding secrets
1. Push to `main`. The Forgejo workflow brings Postgres and MinIO up, then runs
`postgres-init` (creates `asxpio` role + DB) and `minio-init` (creates
`asxpio-invoices` bucket and an `asxpio`-scoped user).
2. Confirm both init containers exited 0:
```sh
ssh deploy@prod 'docker compose -f /opt/storage/docker-compose.yml ps'
ssh deploy@prod 'docker logs storage-postgres-init storage-minio-init'
```
3. Add the matching `ASXPIO_DB_PASSWORD`, `ASXPIO_S3_ACCESS_KEY`,
`ASXPIO_S3_SECRET_KEY` (plus `ADMIN_USER`, `ADMIN_PASSWORD`) on the asxpio
repo — see `asxpio/SECRETS.md`.
4. Deploy asxpio.
## Rotation
- **`POSTGRES_PASSWORD` / `MINIO_ROOT_PASSWORD`**: update secret, push. Init
scripts only touch *per-app* roles, so the root passwords are applied on
container restart (Postgres) or via the env on next boot (MinIO). For MinIO
root rotation, recreating the container is enough.
- **Per-app password / keys**: update both repos' secrets, push storage first
(re-provisions the role/user with the new password), then push the consumer.

93
docker-compose.yml Normal file
View file

@ -0,0 +1,93 @@
services:
postgres:
image: postgres:17-alpine
container_name: storage-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postgres
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- storage
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
postgres-init:
image: postgres:17-alpine
container_name: storage-postgres-init
depends_on:
postgres:
condition: service_healthy
environment:
PGHOST: postgres
PGUSER: postgres
PGPASSWORD: ${POSTGRES_PASSWORD}
ASXPIO_DB_PASSWORD: ${ASXPIO_DB_PASSWORD}
volumes:
- ./init/postgres-init.sh:/init.sh:ro
entrypoint: ["/bin/sh", "/init.sh"]
networks:
- storage
restart: "no"
minio:
image: minio/minio:latest
container_name: storage-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_BROWSER_REDIRECT_URL: https://s3.asxp.io
volumes:
- minio_data:/data
networks:
- storage
- traefik
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.minio-s3.rule=Host(`s3.asxp.io`)"
- "traefik.http.routers.minio-s3.entrypoints=websecure"
- "traefik.http.routers.minio-s3.tls=true"
- "traefik.http.routers.minio-s3.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-s3.service=minio-s3"
- "traefik.http.services.minio-s3.loadbalancer.server.port=9000"
minio-init:
image: minio/mc:latest
container_name: storage-minio-init
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
ASXPIO_S3_ACCESS_KEY: ${ASXPIO_S3_ACCESS_KEY}
ASXPIO_S3_SECRET_KEY: ${ASXPIO_S3_SECRET_KEY}
volumes:
- ./init/minio-bootstrap.sh:/bootstrap.sh:ro
entrypoint: ["/bin/sh", "/bootstrap.sh"]
networks:
- storage
restart: "no"
volumes:
pg_data:
minio_data:
networks:
storage:
name: storage
traefik:
external: true

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
description = "storage Postgres + MinIO for asxp.io";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
pkgs.postgresql_17
pkgs.minio-client
pkgs.docker-compose
pkgs.openssl
];
shellHook = ''
if [ -f .env ]; then
set -a
source .env
set +a
fi
echo "storage dev shell ready"
echo " docker compose up -d - bring stack up"
echo " psql -h localhost ... - if you forward 5432"
echo " mc alias set local ... - point mc at MinIO"
'';
};
};
}

56
init/minio-bootstrap.sh Normal file
View file

@ -0,0 +1,56 @@
#!/bin/sh
# Idempotent MinIO bootstrap: bucket + per-app service account scoped to that
# bucket. Re-running just refreshes the policy and resets the access key.
set -eu
mc alias set local http://minio:9000 "${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}"
ensure_bucket() {
bucket="$1"
if ! mc ls "local/${bucket}" >/dev/null 2>&1; then
mc mb "local/${bucket}"
fi
mc anonymous set none "local/${bucket}" >/dev/null
}
write_policy() {
name="$1"
bucket="$2"
cat > "/tmp/${name}.json" <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": ["arn:aws:s3:::${bucket}"]
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": ["arn:aws:s3:::${bucket}/*"]
}
]
}
EOF
mc admin policy create local "${name}" "/tmp/${name}.json" 2>/dev/null \
|| mc admin policy update local "${name}" "/tmp/${name}.json"
}
ensure_user() {
access="$1"
secret="$2"
policy="$3"
if mc admin user info local "${access}" >/dev/null 2>&1; then
# Rotate secret to match desired state.
mc admin user remove local "${access}" >/dev/null
fi
mc admin user add local "${access}" "${secret}"
mc admin policy attach local "${policy}" --user "${access}" 2>/dev/null || true
}
ensure_bucket "asxpio-invoices"
write_policy "asxpio-invoices-rw" "asxpio-invoices"
ensure_user "${ASXPIO_S3_ACCESS_KEY}" "${ASXPIO_S3_SECRET_KEY}" "asxpio-invoices-rw"
echo "minio-init: done"

29
init/postgres-init.sh Normal file
View file

@ -0,0 +1,29 @@
#!/bin/sh
# Idempotent bootstrap: create per-app roles and databases.
# Re-running is safe — checks before creating, updates passwords if changed.
set -eu
ensure_role() {
role="$1"
password="$2"
exists=$(psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${role}'")
if [ "${exists}" = "1" ]; then
psql -c "ALTER ROLE \"${role}\" WITH LOGIN PASSWORD '${password}';"
else
psql -c "CREATE ROLE \"${role}\" WITH LOGIN PASSWORD '${password}';"
fi
}
ensure_db() {
db="$1"
owner="$2"
exists=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname='${db}'")
if [ "${exists}" != "1" ]; then
psql -c "CREATE DATABASE \"${db}\" OWNER \"${owner}\";"
fi
}
ensure_role "asxpio" "${ASXPIO_DB_PASSWORD}"
ensure_db "asxpio" "asxpio"
echo "postgres-init: done"