Account & support
Security Overview
8 min read
This page describes Lunadeck's security posture honestly. We are a small team operating a v1.0 product and we'd rather understate than oversell. Where we have not yet built a control or do not yet hold a certification, we say so.
What we claim today, and what we don't
We do claim:
- All data in object storage is encrypted at rest with server-side encryption (SSE-S3).
- Customer signing keystores are additionally encrypted at the application layer with AES-256-GCM before being written to storage. A leaked storage bucket is not a leaked keystore.
- Postgres connections are TLS-required (
sslmode=require); the underlying disks are LUKS-encrypted at the volume layer. - All user-facing endpoints are served over TLS 1.2+ behind Caddy, with HSTS.
- We have a documented data-deletion path triggered by user action that hard-deletes account data within minutes (with a customer-facing 30-day SLA promise).
We do not claim:
- No SOC 2 or ISO 27001 at v1.0. Customers whose procurement requires either should route to Contact us for Enterprise; we'll talk about timelines candidly.
- No HIPAA, PCI DSS, or FedRAMP coverage. Don't put PHI or cardholder data in a Lunadeck source bundle. (Stripe handles cardholder data for our own billing; we never touch a customer card number.)
- No formal third-party pen test at v1.0. We run internal review and an automated security review (
/security-review) on every PR; we'll commission an external pen test after launch traffic stabilises. - No customer-controlled KMS at v1.0. The keystore master key is a manually-rotated environment secret stored in our Ansible vault; AWS/Hetzner KMS migration is on the post-launch roadmap.
Where your source code lives
Your source archive is stored as a single .zip in our MinIO object store under a path like sources/<source_id>.zip. The bucket is server-side encrypted (SSE-S3). The bucket itself is single-tenant — Lunadeck operates one bucket per environment (staging / production), and isolation between organizations is enforced at the application layer:
- Every source row in our database is owned by an organization.
- API handlers verify on every read that the requesting user belongs to that org.
- Worker pulls (build, AI check) operate on object keys derived from authenticated row IDs.
What this means in practice: if our database were compromised but the bucket were not, an attacker could not read source code. If the bucket were compromised but the database were not, an attacker would see file paths and could read the encrypted contents but would not see who owned which source.
Build artifacts (APKs) live in the same bucket under builds/<build_id>/app.apk, encrypted server-side, with the same row-level access control.
How keystores are encrypted
Customer-uploaded signing keystores (.jks / .keystore) get a second layer of encryption on top of SSE-S3, because the cost of a leaked keystore is higher than the cost of leaked source.
The envelope
Each encrypted keystore is stored as a self-describing envelope:
version(1 byte) || nonce(12 bytes) || AES-256-GCM(ciphertext + tag)
- The version byte records which generation of master key produced the envelope. A future key rotation can keep decrypting old envelopes while encrypting new ones with a new version.
- The nonce is 12 random bytes per encryption, sourced from
crypto/rand. - The ciphertext + tag is the keystore bytes sealed with AES-256-GCM. The organization ID is bound in as GCM additional authenticated data, so an envelope that was encrypted for org A cannot be replayed against org B's derived key (the AEAD will reject it).
Master key handling
- One 32-byte master key (
KEYSTORE_MASTER_KEY) lives in our Ansible-vault-encrypted secrets, decrypted only on the deploy host and injected into the API and worker containers as an environment variable at start time. - That master key never appears in source, in logs, in error messages, in build artifacts, or in any database row.
- Each organization gets a distinct derived key computed via HKDF-SHA256 with the master key as input, no salt, and an info string of
"lunadeck-keystore-v1|<org_id>". Compromising one org's derived key tells you nothing about another org's derived key without also compromising the master key. - Plaintext keystore bytes exist only inside the build worker's memory and on its temporary workspace disk for the duration of a single build. The workspace is wiped (
defer os.RemoveAll) when the build process returns.
What this protects against, and what it doesn't
This design protects against: leaked object storage, attacker reading a stolen DB dump, cross-tenant replay attacks within Lunadeck's own infrastructure.
It does not protect against: a compromise of our deploy host or live API host that exposes KEYSTORE_MASTER_KEY in process memory. Mitigating that requires hardware-backed KMS, which is on our post-launch roadmap.
Data in transit
- User → Lunadeck: TLS 1.2+ via Caddy. HSTS header is set on all user-facing hostnames.
- Lunadeck → Stripe / Clerk / OpenRouter / AWS SES: TLS via the respective SDK. We do not pin certificates at v1.
- Lunadeck internal (container → container): unencrypted on the private Docker network behind the host firewall. Postgres connections specifically require TLS (
sslmode=require) so even on the internal network, DB traffic is encrypted. Redis runs without auth on the internal network only — never exposed publicly. - Worker → MinIO: TLS to the standard S3-compatible endpoint.
Per-organization isolation
There is no shared "all customers" pool in Lunadeck's data model. Every project, source, build, signing profile, and AI check row is owned by an organization. API handlers validate org membership on every read and write. The keystore encryption scheme makes that isolation defense-in-depth: even if a bug let one org reference another org's encrypted keystore envelope, decryption would fail because the org ID is bound as GCM AAD.
Retention by plan
Build artifacts and source archives are kept according to your plan's retention window. The retention sweeper runs daily and deletes artifacts that have passed their expires_at:
- Hobby: 7 days
- Starter: 30 days
- Team: 90 days
- Business: 365 days
Cross-link: Account & Billing > Plans for the full quota matrix.
When an artifact expires, it is hard-deleted from object storage. The database row stamps purged_at for audit, but the bytes are gone. Subsequent attempts to download the artifact return a clean 404.
Data deletion
Account deletion
You can delete your account at any time from Settings > Account > Delete account. This calls DELETE /v1/me and:
- Immediately anonymizes your user row — your email is replaced with a deterministic
deleted-<id>@deleted.lunadeck.io, your name and avatar are cleared, your Clerk user record is deleted on Clerk's side, and we setplan_status = "cancelled"andpending_deletion_at = now(). - Asynchronously (within minutes) a background sweeper hard-deletes the organizations you own and all dependent data: projects, sources, builds (including the object storage bytes), signing profiles (encrypted keystore bytes wiped from MinIO), notifications, chat history, starred projects.
- Stripe-side, your customer record is deleted on Stripe so we don't keep a billing identity around for an account that no longer exists.
Our public SLA promise is "complete deletion within 30 days". The actual sweeper takes well under a minute in normal operation; the 30-day promise is the conservative ceiling.
Organization or project deletion
Deleting an organization (Owner-only, from Settings > Organization) cascades through projects, sources, builds, and signing profiles for that org with the same hard-delete behaviour as account deletion. Deleting a single project does the same but scoped to that project.
Right to be forgotten (GDPR Article 17)
EU users with right-to-be-forgotten requests can use the in-app deletion flow above, or — if they prefer a paper trail — email privacy@lunadeck.io. We acknowledge within 5 business days and complete the action within the 30-day window the GDPR specifies. We do not have a self-serve DSAR portal at v1.0; the email path is the documented process.
Secrets management
All long-lived secrets live in Ansible-vault-encrypted YAML files in this repository (deploy/ansible/inventory/group_vars/<env>/vault.yml). The vault password is never committed; it lives on the deploy host only. The vault is decrypted on the deploy host during Ansible runs and the resulting secrets are injected into containers as environment variables.
Secrets that ride this path at launch:
CLERK_SECRET_KEY— auth providerSTRIPE_SECRET_KEY+STRIPE_WEBHOOK_SECRET— billingOPENROUTER_API_KEY— AI ChecksAWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY— outbound SES emailKEYSTORE_MASTER_KEY— keystore encryption (see How keystores are encrypted)LUNADECK_DEBUG_KEYSTORE_PASSWORD— the shared debug keystore password for Hobby buildsMAGIC_PREVIEW_JWT_SECRET— the HS256 signing secret for transient Magic preview tokens- Postgres / MinIO root passwords
Application code logs neither secrets nor decrypted keystore bytes. The keystorecrypto package in particular has a one-line invariant at the top of the file: "Nothing in this package logs plaintext or keys."
Reporting a security issue
If you believe you've found a vulnerability in Lunadeck, please email security@lunadeck.io. We don't run a paid bug bounty at v1.0 but we will respond within 5 business days, credit you in any resulting fix, and act in good faith. We ask in return that you don't disclose publicly before we've had a chance to fix the issue.
For routine support questions, use support@lunadeck.io instead.