
Geschreven door Funs Janssen
Software Consultant
Ik ben Funs Janssen. Ik ontwikkel software en schrijf over de beslissingen die daarbij komen kijken: architectuur, ontwikkelmethoden, AI-tools en de zakelijke impact van technische keuzes. Deze blog is een verzameling praktische aantekeningen van echte projecten: wat schaalbaar is, wat misgaat en wat in blogvriendelijke voorbeelden vaak over het hoofd wordt gezien.
Babygram is a private, self-hosted photo feed for your family. You run the server, you keep the photos, and an admin decides who may look and who may post. This page takes you from an empty folder to a working server and a phone that can see the feed.
Plan for about fifteen minutes.
Before you start
You need a machine with Docker and the Docker Compose plugin (docker compose version should print something), roughly 2 GB of RAM and enough disk for the photos you plan to keep, and - for anything beyond a quick test - a domain name pointing at that machine so you can serve it over HTTPS. Tokens and photos travel between the app and the server, so plain HTTP is fine only while you are trying things out on your own network.
1. Create the project folder
mkdir -p ~/babygram && cd ~/babygram
Everything below lives in this folder. The photos and the database are kept in Docker volumes, not in the folder itself, so the folder stays small.
2. Add docker-compose.yaml
This is the whole stack: PostgreSQL plus the Babygram API. Save it as docker-compose.yaml.
1services:2 postgres:3 image: "docker.io/library/postgres:18.3"4 pull_policy: "always"5 environment:6 POSTGRES_HOST_AUTH_METHOD: "scram-sha-256"7 POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"8 POSTGRES_USER: "postgres"9 POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"10 expose:11 - "5432"12 volumes:13 - type: "volume"14 target: "/var/lib/postgresql"15 source: "babygram-pgdata"16 read_only: false17 apiservice:18 image: "ghcr.io/funsjanssen/babygram-api"19 pull_policy: "always"20 environment:21 OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory"22 ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"23 HTTP_PORTS: "8080"24 ConnectionStrings__babygramdb: "Host=postgres;Port=5432;Username=postgres;Password=${POSTGRES_PASSWORD};Database=babygramdb"25 BABYGRAMDB_HOST: "postgres"26 BABYGRAMDB_PORT: "5432"27 BABYGRAMDB_USERNAME: "postgres"28 BABYGRAMDB_PASSWORD: "${POSTGRES_PASSWORD}"29 BABYGRAMDB_DATABASENAME: "babygramdb"30 BABYGRAM_Storage__DataPath: "/data/photos"31 BABYGRAM_Jwt__SigningKey: "${JWT_SIGNING_KEY}"32 BABYGRAM_Bootstrap__AdminEmail: "${ADMIN_EMAIL}"33 BABYGRAM_Bootstrap__AdminPassword: "${ADMIN_PASSWORD}"34 ports:35 - "8080:8080"36 volumes:37 - type: "volume"38 target: "/data"39 source: "babygram-photos"40 depends_on:41 postgres:42 condition: "service_started"43 restart: "unless-stopped"44volumes:45 babygram-pgdata:46 driver: "local"47 babygram-photos: {}
A few things worth knowing about this file:
- Postgres is not reachable from outside. It uses
expose, notports, so only the API container can talk to it. - The
ports: - "8080:8080"line publishes the API to the host. Keep it if you are testing on your own network. Remove it once a reverse proxy on the same Docker network terminates HTTPS for you, so the API is never reachable unencrypted. ASPNETCORE_FORWARDEDHEADERS_ENABLEDis already on, so the API honours theX-Forwarded-*headers your proxy sends.pull_policy: alwayswith an untagged image means you followlatest. For a server you care about, pin a version instead, for exampleghcr.io/funsjanssen/babygram-api:v1.2.0.
3. Create the .env file
Compose reads .env from the same folder and fills in the ${...} placeholders above. Generate real secrets rather than copying these:
cat > .env <<EOFPOSTGRES_PASSWORD=$(openssl rand -base64 24)JWT_SIGNING_KEY=$(openssl rand -base64 48)[email protected]ADMIN_PASSWORD=$(openssl rand -base64 18)EOFchmod 600 .env
Open the file afterwards and note the admin password, you will need it to sign in. The signing key must be at least 32 characters; changing it later signs everyone out. Admin passwords need at least 8 characters, and Identity's default rules still apply (upper case, lower case, and a digit).
4. Start it
docker compose up -ddocker compose logs -f apiservice
On the first run the API creates the database schema and the admin account from ADMIN_EMAIL and ADMIN_PASSWORD. When the log settles, check that it answers:
curl http://localhost:8080/api/health# {"status":"ok"}
That endpoint is anonymous and is the right thing to point an uptime monitor at.
5. Put HTTPS in front of it
Skip this only for a throwaway test on your own network. With Caddy the whole configuration is two lines. Put this in a Caddyfile next to the compose file, add a Caddy service to the same compose project, and drop the ports mapping from apiservice:
babygram.yourdomain.com { reverse_proxy apiservice:8080}
Nginx and Traefik work equally well; the only requirement is that the proxy forwards to port 8080 on the apiservice container and sets the usual X-Forwarded-Proto and X-Forwarded-For headers.
If you would rather not manage a proxy yourself, the same compose file runs on Coolify, which handles certificates for you using Traefik.
6. Sign in from the app
Install the Babygram app on your phone. On first launch it asks for the server URL. Enter the public address, for example https://babygram.yourdomain.com (or http://<server-ip>:8080 while testing). Then sign in with the admin email and password from your .env.
7. Invite the family
There is no open sign-up. Everyone joins with a one-time invite code that you create in the app's Admin panel, choosing the role as you create it:
Role | View photos | Comment and react | Upload | Manage members and settings |
|---|---|---|---|---|
Admin | yes | yes | yes | yes |
Contributor | yes | yes | yes | - |
Viewer | yes | yes | - | - |
Send the code to the person however you like. They install the app, enter the same server URL, and register with their email, a password, and that code. The role you picked is applied automatically and the code cannot be used a second time.
Configuration reference
Application settings are environment variables prefixed with BABYGRAM_, with __ for nesting.
Variable | Purpose | Default |
|---|---|---|
| PostgreSQL connection string | required |
| JWT signing secret, 32 characters or more | required in production |
| Access token lifetime |
|
| Refresh token lifetime |
|
| First-run admin account | none |
| Where photos are written inside the container |
|
| Maximum size per uploaded file |
|
| Allow a browser origin, only needed for web clients | none |
The site name shown in the app is not an environment variable; an admin changes it at runtime in the Admin panel.
Keeping it running
Updating. docker compose pull && docker compose up -d. Migrations run automatically at startup, so there is no separate upgrade step. Back up first if you pinned nothing and are jumping several versions.
Backups. Two things matter: the babygram-pgdata volume (accounts, posts, comments) and the babygram-photos volume (the images). A database dump plus a copy of the photo volume covers you:
docker compose exec postgres pg_dump -U postgres babygramdb > babygram-$(date +%F).sqldocker run --rm -v babygram-photos:/data -v "$PWD":/backup alpine \ tar czf /backup/babygram-photos-$(date +%F).tar.gz -C /data .
Test a restore occasionally. A backup you have never restored is a hypothesis.
When something goes wrong
The API container restarts in a loop. Read docker compose logs apiservice. The usual causes are a JWT_SIGNING_KEY shorter than 32 characters, an ADMIN_PASSWORD that fails the complexity rules, or Postgres not being ready yet on a slow first boot. The API retries, so give it a minute before concluding anything.
curl to /api/health hangs or is refused. Either the ports mapping is missing from apiservice, or a firewall on the host is blocking 8080. From inside the network you can always check with docker compose exec apiservice curl -s localhost:8080/api/health.
The app says it cannot reach the server. Nine times out of ten the URL is missing the scheme or the port. Try the exact same URL in a phone browser with /api/health appended; if the browser cannot reach it either, the problem is the network or the proxy, not the app.
The admin account does not exist after changing .env. The bootstrap admin is only created when the account is not already there. Change an existing password from the Admin panel instead.
Reacties
Nog geen reacties. Wees de eerste om te reageren.
Plaats een reactie

Geschreven door Funs Janssen
Software Consultant
Ik ben Funs Janssen. Ik ontwikkel software en schrijf over de beslissingen die daarbij komen kijken: architectuur, ontwikkelmethoden, AI-tools en de zakelijke impact van technische keuzes. Deze blog is een verzameling praktische aantekeningen van echte projecten: wat schaalbaar is, wat misgaat en wat in blogvriendelijke voorbeelden vaak over het hoofd wordt gezien.
