HealthTech GDPR Part 1: Database Design for a Medication Reminder App
Medication reminder apps look simple from the outside.
A user creates an account, adds the medication they are taking, chooses a schedule, and receives a push notification when it is time for the next dose.
That product flow is straightforward. The data model is not especially complicated either.
But the moment an app stores the name of a medication, it starts handling information that can reveal something about a person’s health. That changes the way we should design the system.
In this series, we will build MediTrack, a mobile medication reminder app using:
- Ruby on Rails 8 for the API
- PostgreSQL for persistence
- Ionic React for the mobile frontend
- Push notifications for medication reminders
- Privacy by design as a core architectural constraint
This first post focuses on the foundation: what GDPR means for this kind of healthtech app, how to think about explicit consent, and how to design a database that treats health data carefully from day one.
This is a technical guide, not legal advice. For a real product that processes health data in production, work with a qualified privacy or data protection professional.
Series focus: We are not trying to make a legal checklist look like code. We are designing the product so privacy requirements shape the architecture before the first controller is written.
What we are building
MediTrack will let patients:
- Create an account
- Store basic profile preferences
- Add medications with name, dosage, and frequency
- Configure reminder schedules
- Receive push notifications when a medication is due
- Export or delete their data
- Withdraw consent and disable reminders
The app is intentionally small. The goal is not to build a full electronic health record system.
The goal is to build a practical medication reminder app while treating privacy, consent, data minimization, and deletion as first-class product requirements.
That matters because healthtech products often fail when compliance is bolted on after the core experience has already been built. Privacy decisions affect the database, authentication, logs, background jobs, admin tooling, analytics, and support workflows.
So we will start with the data model.
Why GDPR matters for a medication reminder app
The General Data Protection Regulation, usually called GDPR, is the European Union’s privacy and data protection law. It has applied since May 25, 2018, and it can affect organizations outside the EU when they process personal data from people in the EU or target services to them.
For MediTrack, the important point is simple:
A medication name can reveal information about someone’s health.
Metformin may suggest diabetes treatment. Sertraline may suggest treatment for depression or anxiety. Insulin, antiretrovirals, chemotherapy medication, hormone therapy, and many other examples can reveal sensitive context about a person’s physical or mental health.
That means medication data should be treated as health data, which is a special category of personal data under GDPR Article 9.
Key takeaway: In healthtech, the sensitive field is not always obvious. A simple medication name can reveal enough context to require stronger consent, stronger storage practices, and cleaner deletion workflows.
Useful official references:
- European Commission: data protection by design and by default
- European Commission: valid consent under GDPR
- Your Europe: data protection under GDPR
- European Data Protection Board: be compliant
GDPR principles translated into product requirements
GDPR Article 5 defines core principles for processing personal data. For a medication reminder app, those principles are not abstract legal language. They become product and engineering requirements.
| GDPR principle | What it means for MediTrack |
|---|---|
| Lawfulness, fairness, and transparency | Users need to understand what data is collected, why it is collected, and how it is used. |
| Purpose limitation | Medication data is used for reminders and account features, not unrelated marketing or profiling. |
| Data minimization | The app should only ask for data that is needed to provide the reminder service. |
| Accuracy | Users must be able to correct their profile, medications, and schedules. |
| Storage limitation | Data should not be retained forever when the user deletes the account or withdraws from the service. |
| Integrity and confidentiality | Sensitive fields should be protected through encryption, access controls, and secure transport. |
| Accountability | The system should be able to demonstrate consent, deletion workflows, and access patterns. |
This series will turn those ideas into code.
Health data and explicit consent
GDPR Article 9 gives special category data extra protection. Health data is one of those categories.
As a general rule, processing special category data is prohibited unless a specific exception applies. For a patient-facing medication reminder app, the most relevant basis is often explicit consent for a specific purpose.
That does not mean a vague “I accept the terms” checkbox is enough.
For this kind of app, the consent experience should make clear that:
- the app will process medication and reminder data;
- medication data may reveal health information;
- the data is used to provide medication reminders and related account features;
- the user can withdraw consent;
- withdrawing consent may limit or stop medication reminder features;
- withdrawing consent should be as easy as giving it.
From an implementation perspective, that means consent cannot live only as a boolean column on users.
We need a separate consent history.
Data subject rights we need to support
GDPR gives people rights over their personal data. A practical API should make those rights possible without turning every request into manual support work.
For MediTrack, we will design for:
| Right | Article | Implementation direction |
|---|---|---|
| Access | Article 15 | Endpoint to view or download the user’s stored data. |
| Rectification | Article 16 | Users can edit profile details, medication records, and schedules. |
| Erasure | Article 17 | Account deletion flow and background purge job. |
| Portability | Article 20 | Export user data as JSON, and later CSV if needed. |
| Objection or opt-out | Article 21 | Users can disable push notifications without deleting the account. |
This is one reason I prefer to define GDPR-related workflows early. Data export and deletion are much easier when the schema was designed with them in mind.
Privacy by design
Privacy by design means the privacy model is built into the architecture instead of added later as a policy page.
For MediTrack, that means:
-
Separate identity from health data where practical. Medication records belong to a user, but they do not duplicate name, email, or profile details.
-
Use UUID primary keys. UUIDs make direct record enumeration harder than sequential integer IDs.
-
Encrypt sensitive columns. Email, names, medication names, dosages, notes, and push tokens should not be stored as plain text.
-
Keep an audit trail for sensitive actions. Consent changes, data export, deletion, and access to health data should be traceable.
-
Keep defaults restrictive. Push notifications should require user action. Health data processing should require explicit consent.
-
Build deletion into the model. Soft deletion gives us a controlled account deletion flow, while a purge job can remove records permanently after the configured retention window.
There is an important nuance here: soft delete does not replace the right to erasure. It is a staging mechanism. If the user requests deletion and there is no valid reason to retain the data, the system needs a real purge process.
Database design for MediTrack
Here is the first version of the data model.
users
- id uuid
- email_ciphertext
- email_bidx
- password_digest
- confirmed_at
- deleted_at
- created_at
- updated_at
user_profiles
- id uuid
- user_id uuid
- full_name_ciphertext
- date_of_birth_ciphertext
- timezone
- locale
- created_at
- updated_at
medications
- id uuid
- user_id uuid
- name_ciphertext
- dosage_ciphertext
- dosage_unit
- frequency_type
- frequency_value
- start_date
- end_date
- notes_ciphertext
- active
- deleted_at
- created_at
- updated_at
reminder_schedules
- id uuid
- medication_id uuid
- scheduled_time
- days_of_week
- active
- created_at
- updated_at
push_tokens
- id uuid
- user_id uuid
- token_ciphertext
- platform
- active
- created_at
- updated_at
consents
- id uuid
- user_id uuid
- consent_type
- granted
- ip_address
- user_agent
- granted_at
- withdrawn_at
- created_at
- updated_at
notification_logs
- id uuid
- user_id uuid
- reminder_schedule_id uuid
- sent_at
- status
- error_message
- created_at
- updated_at
Why these tables exist
users
The users table owns authentication identity.
Email is encrypted with Lockbox and searchable through a blind index. That gives us login lookup without storing the email address in plain text.
We also keep deleted_at for the account deletion workflow.
user_profiles
The profile stores personal details that are useful for the app experience, such as full name, date of birth, timezone, and locale.
The name and date of birth are encrypted. Timezone and locale are not encrypted because they are needed operationally and are less sensitive in this context, but they should still be treated as personal data when exported or deleted.
medications
This is the most sensitive table in the application.
The medication name, dosage, and notes are encrypted because they can directly reveal health context.
The frequency fields remain queryable because background jobs need to find schedules and reminders efficiently. The design keeps scheduling metadata separate from the medication content.
reminder_schedules
Reminder schedules store when a medication should trigger a notification.
They belong to a medication and do not contain the medication name. That matters because a background job can often work with IDs and schedules without loading decrypted health data until absolutely necessary.
push_tokens
Push tokens connect a device to a user.
They are encrypted because a token can be used to reach a user’s device and should be handled as sensitive operational data.
consents
Consent is modeled as a history, not a single field.
Every grant or withdrawal can be stored with a timestamp, IP address, and user agent. That gives us an auditable record of what the user agreed to and when.
notification_logs
Notification logs help us troubleshoot delivery without storing the full reminder message in plain text.
The log can say that a notification was sent, delivered, or failed. It does not need to store “Take 500mg of X medication” as readable text.
Creating the Rails 8 API project
Start with a Rails API-only application using PostgreSQL.
rails new meditrack-api \
--database=postgresql \
--api \
--skip-action-cable \
--skip-hotwire \
--skip-asset-pipeline
cd meditrack-api
The --api flag gives us a lighter Rails app without server-rendered views or browser session middleware. That fits this architecture because Ionic React will be a separate mobile frontend.
Gemfile
Here is the initial set of dependencies.
source "https://rubygems.org"
ruby "3.3.0"
gem "rails", "~> 8.0"
gem "pg", "~> 1.5"
gem "puma", ">= 5.0"
gem "bootsnap", require: false
# Authentication
gem "devise", "~> 4.9"
gem "devise-jwt", "~> 0.11"
# Column encryption and searchable encrypted values
gem "lockbox", "~> 1.3"
gem "blind_index", "~> 2.3"
# Soft delete
gem "discard", "~> 1.2"
# Serialization
gem "jsonapi-serializer", "~> 2.2"
# Background jobs for reminders
gem "sidekiq", "~> 7.2"
gem "sidekiq-scheduler", "~> 5.0"
# Push notifications
gem "rpush", "~> 7.0"
# Security
gem "rack-attack", "~> 6.7"
gem "secure_headers", "~> 6.5"
# Audit trail
gem "paper_trail", "~> 15.1"
# Environment variables
gem "dotenv-rails", groups: [:development, :test]
# CORS for the Ionic frontend
gem "rack-cors", "~> 2.0"
group :development, :test do
gem "rspec-rails", "~> 6.1"
gem "factory_bot_rails"
gem "faker"
gem "shoulda-matchers"
gem "brakeman"
gem "bundler-audit"
end
Then install the dependencies:
bundle install
Project structure
This is the structure we will build toward during the series.
meditrack-api/
├── app/
│ ├── controllers/
│ │ └── api/
│ │ └── v1/
│ │ ├── auth/
│ │ │ ├── registrations_controller.rb
│ │ │ └── sessions_controller.rb
│ │ ├── medications_controller.rb
│ │ ├── reminder_schedules_controller.rb
│ │ ├── users/
│ │ │ ├── consents_controller.rb
│ │ │ └── data_controller.rb
│ │ └── push_tokens_controller.rb
│ ├── models/
│ │ ├── user.rb
│ │ ├── user_profile.rb
│ │ ├── medication.rb
│ │ ├── reminder_schedule.rb
│ │ ├── push_token.rb
│ │ ├── consent.rb
│ │ └── notification_log.rb
│ ├── jobs/
│ │ ├── send_medication_reminder_job.rb
│ │ └── purge_deleted_users_job.rb
│ ├── services/
│ │ ├── gdpr/
│ │ │ ├── data_export_service.rb
│ │ │ └── account_deletion_service.rb
│ │ └── push_notification_service.rb
│ └── serializers/
│ ├── user_serializer.rb
│ ├── medication_serializer.rb
│ └── reminder_schedule_serializer.rb
├── config/
│ ├── initializers/
│ │ ├── cors.rb
│ │ ├── lockbox.rb
│ │ ├── rack_attack.rb
│ │ └── secure_headers.rb
│ └── routes.rb
└── db/
└── migrate/
├── 001_enable_extensions.rb
├── 002_create_users.rb
├── 003_create_user_profiles.rb
├── 004_create_medications.rb
├── 005_create_reminder_schedules.rb
├── 006_create_push_tokens.rb
├── 007_create_consents.rb
└── 008_create_notification_logs.rb
Enabling PostgreSQL UUID support
Create the database first:
rails db:create
Then generate the migration:
rails generate migration EnableExtensions
class EnableExtensions < ActiveRecord::Migration[8.0]
def change
enable_extension "pgcrypto"
end
end
PostgreSQL’s pgcrypto extension allows Rails to use native UUID generation.
Migrations
Generate the migrations:
rails generate migration CreateUsers
rails generate migration CreateUserProfiles
rails generate migration CreateMedications
rails generate migration CreateReminderSchedules
rails generate migration CreatePushTokens
rails generate migration CreateConsents
rails generate migration CreateNotificationLogs
Users
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users, id: :uuid do |t|
t.string :email_ciphertext, null: false
t.string :email_bidx, null: false
t.string :password_digest, null: false
t.string :confirmation_token
t.datetime :confirmed_at
t.datetime :confirmation_sent_at
t.datetime :deleted_at
t.timestamps
end
add_index :users, :email_bidx, unique: true
add_index :users, :deleted_at
end
end
User profiles
class CreateUserProfiles < ActiveRecord::Migration[8.0]
def change
create_table :user_profiles, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.string :full_name_ciphertext
t.string :date_of_birth_ciphertext
t.string :timezone, default: "UTC"
t.string :locale, default: "en"
t.timestamps
end
end
end
Medications
class CreateMedications < ActiveRecord::Migration[8.0]
def change
create_table :medications, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.text :name_ciphertext, null: false
t.string :dosage_ciphertext
t.string :dosage_unit
t.string :notes_ciphertext
t.string :frequency_type, null: false, default: "daily"
t.integer :frequency_value, null: false, default: 1
t.date :start_date
t.date :end_date
t.boolean :active, default: true, null: false
t.datetime :deleted_at
t.timestamps
end
add_index :medications, [:user_id, :active]
add_index :medications, :deleted_at
end
end
Reminder schedules
class CreateReminderSchedules < ActiveRecord::Migration[8.0]
def change
create_table :reminder_schedules, id: :uuid do |t|
t.references :medication, null: false, foreign_key: true, type: :uuid
t.time :scheduled_time, null: false
t.integer :days_of_week, array: true, default: []
t.boolean :active, default: true, null: false
t.timestamps
end
add_index :reminder_schedules, [:medication_id, :active]
end
end
Push tokens
class CreatePushTokens < ActiveRecord::Migration[8.0]
def change
create_table :push_tokens, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.string :token_ciphertext, null: false
t.string :platform, null: false
t.boolean :active, default: true, null: false
t.timestamps
end
add_index :push_tokens, [:user_id, :active]
end
end
Consents
class CreateConsents < ActiveRecord::Migration[8.0]
def change
create_table :consents, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.string :consent_type, null: false
t.boolean :granted, null: false
t.string :ip_address
t.string :user_agent
t.datetime :granted_at
t.datetime :withdrawn_at
t.timestamps
end
add_index :consents, [:user_id, :consent_type]
end
end
Notification logs
class CreateNotificationLogs < ActiveRecord::Migration[8.0]
def change
create_table :notification_logs, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.references :reminder_schedule, null: false, foreign_key: true, type: :uuid
t.datetime :sent_at
t.string :status, null: false, default: "pending"
t.string :error_message
t.timestamps
end
add_index :notification_logs, [:user_id, :sent_at]
add_index :notification_logs, :status
end
end
Run the migrations:
rails db:migrate
Configuring Lockbox
Generate a Lockbox master key:
rails runner "puts Lockbox.generate_key"
Store the value in .env. Do not commit this key.
LOCKBOX_MASTER_KEY=your_generated_key_here
DATABASE_URL=postgresql://localhost/meditrack_api_development
Then configure Lockbox:
# config/initializers/lockbox.rb
Lockbox.master_key = ENV.fetch("LOCKBOX_MASTER_KEY") do
raise "LOCKBOX_MASTER_KEY is not configured. Generate one with: rails runner \"puts Lockbox.generate_key\""
end
First model layer
User
class User < ApplicationRecord
include Discard::Model
encrypts :email
blind_index :email
has_one :user_profile, dependent: :destroy
has_many :medications, dependent: :destroy
has_many :consents, dependent: :destroy
has_many :push_tokens, dependent: :destroy
validates :email, presence: true, uniqueness: true
validates :password_digest, presence: true
end
Medication
class Medication < ApplicationRecord
include Discard::Model
belongs_to :user
has_many :reminder_schedules, dependent: :destroy
encrypts :name, :dosage, :notes
FREQUENCY_TYPES = %w[daily weekly custom].freeze
DOSAGE_UNITS = %w[mg ml g units drops].freeze
validates :name, presence: true
validates :frequency_type, inclusion: { in: FREQUENCY_TYPES }
validates :dosage_unit, inclusion: { in: DOSAGE_UNITS }, allow_nil: true
scope :active, -> { kept.where(active: true) }
end
Consent
class Consent < ApplicationRecord
belongs_to :user
TYPES = %w[health_data push_notifications terms_of_service].freeze
validates :consent_type, inclusion: { in: TYPES }
validates :granted, inclusion: { in: [true, false] }
scope :active_health_consent, ->(user) {
where(user: user, consent_type: "health_data", granted: true)
.where(withdrawn_at: nil)
.order(created_at: :desc)
.first
}
end
CORS configuration
Ionic will run separately during development, usually on port 8100.
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ENV.fetch("ALLOWED_ORIGINS", "http://localhost:8100").split(",")
resource "/api/*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ["Authorization"],
max_age: 600
end
end
In production, ALLOWED_ORIGINS should be set to the real frontend origin instead of a wildcard.
Initial routes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
devise_for :users,
path: "auth",
controllers: {
registrations: "api/v1/auth/registrations",
sessions: "api/v1/auth/sessions"
}
resources :medications do
resources :reminder_schedules, shallow: true
end
resources :push_tokens, only: [:create, :destroy]
namespace :users do
resource :data, only: [:show, :destroy]
resources :consents, only: [:index, :create, :update]
end
end
end
get "up", to: proc { [200, {}, ["OK"]] }
end
These routes establish the main API surface:
- authentication;
- medication management;
- reminder schedules;
- push token registration;
- consent management;
- data export and account deletion.
Technical summary
If you are building a GDPR-compliant medication reminder app, the hard part is not the reminder schedule itself. The hard part is designing the system so that health data is protected by default.
For MediTrack, that means:
- medication names and notes are encrypted;
- user identity is separated from health records where practical;
- consent is recorded as an auditable history;
- push tokens are encrypted;
- data export and deletion are planned from the first schema;
- background jobs avoid loading decrypted health data unless they need it;
- user-facing consent is explicit, specific, and withdrawable.
This architecture gives us a stronger foundation before we build registration, authentication, push notifications, and the mobile UI.
What comes next
In the next post, we will implement the registration flow with explicit health data consent.
That will include:
- Devise and
devise-jwtsetup - custom registration endpoints
- consent creation during signup
- validation rules for health data processing
- the first medication creation endpoint
MediTrack is still a small app, but it is a useful example because it forces the right engineering question early:
What would change if privacy was part of the data model instead of a paragraph in the footer?